feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,301 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { type Canvas, type FabricObject } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import { useAlign } from '../../src/hooks/use-align';
describe('useAlign', () => {
const createMockCanvas = () => {
const mockCanvas = {
getActiveObject: vi.fn(),
fire: vi.fn(),
requestRenderAll: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
const createMockObject = (props = {}) => {
const mockObject = {
set: vi.fn(),
setCoords: vi.fn(),
getBoundingRect: vi.fn(() => ({
width: 100,
height: 100,
left: 0,
top: 0,
...props,
})),
width: 100,
height: 100,
...props,
};
return mockObject as unknown as FabricObject;
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('alignLeft', () => {
it('应该在 canvas 为 undefined 时不执行任何操作', () => {
const { result } = renderHook(() => useAlign({ canvas: undefined }));
act(() => {
result.current.alignLeft();
});
// 由于没有 canvas不应该有任何操作发生
});
it('应该在选中对象少于 2 个时不执行任何操作', () => {
const mockCanvas = createMockCanvas();
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: [createMockObject()] }),
);
act(() => {
result.current.alignLeft();
});
expect(mockCanvas.fire).not.toHaveBeenCalled();
});
it('应该正确执行左对齐', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject();
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.alignLeft();
});
mockObjects.forEach(obj => {
expect(obj.set).toHaveBeenCalledWith({
left: -mockActiveObject.width / 2,
});
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('alignRight', () => {
it('应该正确执行右对齐', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject();
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.alignRight();
});
mockObjects.forEach(obj => {
expect(obj.set).toHaveBeenCalledWith({
left: mockActiveObject.width / 2 - obj.getBoundingRect().width,
});
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('alignCenter', () => {
it('应该正确执行水平居中对齐', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject();
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.alignCenter();
});
mockObjects.forEach(obj => {
expect(obj.set).toHaveBeenCalledWith({
left: -obj.getBoundingRect().width / 2,
});
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('alignTop', () => {
it('应该正确执行顶部对齐', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject();
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.alignTop();
});
mockObjects.forEach(obj => {
expect(obj.set).toHaveBeenCalledWith({
top: -mockActiveObject.height / 2,
});
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('alignMiddle', () => {
it('应该正确执行垂直居中对齐', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject();
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.alignMiddle();
});
mockObjects.forEach(obj => {
expect(obj.set).toHaveBeenCalledWith({
top: -obj.getBoundingRect().height / 2,
});
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('alignBottom', () => {
it('应该正确执行底部对齐', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject();
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.alignBottom();
});
mockObjects.forEach(obj => {
expect(obj.set).toHaveBeenCalledWith({
top: mockActiveObject.height / 2 - obj.getBoundingRect().height,
});
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('verticalAverage', () => {
it('应该正确执行水平均分', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject({ width: 300 });
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.verticalAverage();
});
expect(mockObjects[0].set).toHaveBeenCalledWith({ left: -150 });
expect(mockObjects[1].set).toHaveBeenCalledWith({ left: -50 });
expect(mockObjects[2].set).toHaveBeenCalledWith({ left: 50 });
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('horizontalAverage', () => {
it('应该正确执行垂直均分', () => {
const mockCanvas = createMockCanvas();
const mockActiveObject = createMockObject({ height: 300 });
const mockObjects = [
createMockObject(),
createMockObject(),
createMockObject(),
];
(mockCanvas.getActiveObject as any).mockReturnValue(mockActiveObject);
const { result } = renderHook(() =>
useAlign({ canvas: mockCanvas, selectObjects: mockObjects }),
);
act(() => {
result.current.horizontalAverage();
});
expect(mockObjects[0].set).toHaveBeenCalledWith({ top: -150 });
expect(mockObjects[1].set).toHaveBeenCalledWith({ top: -50 });
expect(mockObjects[2].set).toHaveBeenCalledWith({ top: 50 });
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { type Canvas } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import type { FabricSchema } from '../../src/typings';
import { Mode } from '../../src/typings';
import { useBackground } from '../../src/hooks/use-background';
// Mock ahooks
vi.mock('ahooks', () => ({
useDebounceEffect: (fn: () => void, deps: any[], options: any) => {
useEffect(() => {
fn();
}, deps);
},
}));
describe('useBackground', () => {
const createMockCanvas = () => {
const mockCanvas = {
backgroundColor: '#ffffff',
set: vi.fn(),
fire: vi.fn(),
requestRenderAll: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
const createMockSchema = (backgroundColor = '#ffffff'): FabricSchema => ({
width: 800,
height: 600,
background: backgroundColor,
backgroundColor,
objects: [],
customVariableRefs: [],
customType: Mode.RECT,
customId: 'test-canvas',
});
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
it('应该在 canvas 为 undefined 时不设置背景色', () => {
const schema = createMockSchema();
const { result } = renderHook(() =>
useBackground({ canvas: undefined, schema }),
);
expect(result.current.backgroundColor).toBe('#ffffff');
});
it('应该在初始化时从 canvas 获取背景色', () => {
const mockCanvas = createMockCanvas();
const schema = createMockSchema();
const { result } = renderHook(() =>
useBackground({ canvas: mockCanvas, schema }),
);
expect(result.current.backgroundColor).toBe('#ffffff');
});
it('应该在 schema 的背景色变化时更新背景色', async () => {
const mockCanvas = createMockCanvas();
const initialSchema = createMockSchema('#ffffff');
const { result, rerender } = renderHook(
({ currentSchema }) =>
useBackground({ canvas: mockCanvas, schema: currentSchema }),
{
initialProps: { currentSchema: initialSchema },
},
);
// 更新 schema
const newSchema = createMockSchema('#000000');
rerender({ currentSchema: newSchema });
// 等待 debounce
await vi.runAllTimers();
expect(result.current.backgroundColor).toBe('#000000');
expect(mockCanvas.set).toHaveBeenCalledWith({
backgroundColor: '#000000',
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该在背景色变化时更新 canvas', () => {
const mockCanvas = createMockCanvas();
const schema = createMockSchema();
const { result } = renderHook(() =>
useBackground({ canvas: mockCanvas, schema }),
);
act(() => {
result.current.setBackgroundColor('#000000');
});
expect(mockCanvas.set).toHaveBeenCalledWith({
backgroundColor: '#000000',
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该在背景色相同时不重复更新 canvas', () => {
const mockCanvas = createMockCanvas();
const schema = createMockSchema('#ffffff');
const { result } = renderHook(() =>
useBackground({ canvas: mockCanvas, schema }),
);
act(() => {
result.current.setBackgroundColor('#ffffff');
});
expect(mockCanvas.set).not.toHaveBeenCalled();
expect(mockCanvas.fire).not.toHaveBeenCalled();
expect(mockCanvas.requestRenderAll).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,337 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { type Canvas, type FabricObject } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import { ViewVariableType } from '@coze-workflow/base/types';
import { createElement } from '../../src/utils';
import type { FabricSchema } from '../../src/typings';
import { useCanvasChange, saveProps } from '../../src/hooks/use-canvas-change';
enum Mode {
INLINE_TEXT = 'inline_text',
BLOCK_TEXT = 'block_text',
RECT = 'rect',
TRIANGLE = 'triangle',
CIRCLE = 'ellipse',
STRAIGHT_LINE = 'straight_line',
PENCIL = 'pencil',
IMAGE = 'img',
GROUP = 'group',
}
// Mock createElement
vi.mock('../../src/utils', () => ({
createElement: vi.fn(),
defaultProps: {
[Mode.IMAGE]: {
width: 100,
height: 100,
},
[Mode.BLOCK_TEXT]: {
width: 200,
height: 50,
},
},
}));
// Mock getUploadCDNAsset
vi.mock('@coze-workflow/base-adapter', () => ({
getUploadCDNAsset: vi.fn(path => `https://cdn.example.com${path}`),
}));
describe('useCanvasChange', () => {
const createMockCanvas = () => {
const mockCanvas = {
on: vi.fn((event: string, callback: (event: any) => void) =>
// 返回一个清理函数
() => {
mockCanvas.off(event, callback);
},
),
off: vi.fn(),
add: vi.fn(),
setActiveObject: vi.fn(),
toObject: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
const createMockObject = (props = {}) => {
const mockObject = {
customId: 'test-id',
customType: 'test-type',
...props,
};
return mockObject as unknown as FabricObject;
};
const createMockSchema = (overrides = {}): FabricSchema => ({
width: 800,
height: 600,
background: '#ffffff',
customType: Mode.IMAGE,
customId: 'canvas-1',
objects: [],
customVariableRefs: [],
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
});
it('应该在没有 canvas 时不设置事件监听', () => {
const mockOnChange = vi.fn();
renderHook(() =>
useCanvasChange({
canvas: undefined,
onChange: mockOnChange,
}),
);
expect(mockOnChange).not.toHaveBeenCalled();
});
it('应该正确监听画布变化事件', () => {
const mockCanvas = createMockCanvas();
const mockOnChange = vi.fn();
const mockSchema = createMockSchema();
(mockCanvas.toObject as Mock).mockReturnValue(mockSchema);
renderHook(() =>
useCanvasChange({
canvas: mockCanvas,
onChange: mockOnChange,
}),
);
// 验证是否监听了所有默认事件
expect(mockCanvas.on).toHaveBeenCalledWith(
'object:modified',
expect.any(Function),
);
expect(mockCanvas.on).toHaveBeenCalledWith(
'object:added',
expect.any(Function),
);
expect(mockCanvas.on).toHaveBeenCalledWith(
'object:removed',
expect.any(Function),
);
expect(mockCanvas.on).toHaveBeenCalledWith(
'object:moving',
expect.any(Function),
);
expect(mockCanvas.on).toHaveBeenCalledWith(
'object:modified-zIndex',
expect.any(Function),
);
});
it('应该在事件触发时调用 onChange', () => {
const mockCanvas = createMockCanvas();
const mockOnChange = vi.fn();
const mockSchema = createMockSchema();
(mockCanvas.toObject as Mock).mockReturnValue(mockSchema);
renderHook(() =>
useCanvasChange({
canvas: mockCanvas,
onChange: mockOnChange,
}),
);
// 获取 object:modified 事件的回调函数
const modifiedCallback = (mockCanvas.on as any).mock.calls.find(
(call: [string, Function]) => call[0] === 'object:modified',
)?.[1];
// 模拟事件触发
modifiedCallback?.();
expect(mockCanvas.toObject).toHaveBeenCalledWith(saveProps);
expect(mockOnChange).toHaveBeenCalledWith(mockSchema);
});
it('应该在删除对象时重置引用关系', () => {
const mockCanvas = createMockCanvas();
const mockOnChange = vi.fn();
const mockVariables = [
{ id: 'var1', name: 'var1', type: ViewVariableType.String, index: 0 },
{ id: 'var2', name: 'var2', type: ViewVariableType.String, index: 1 },
];
const mockSchema = createMockSchema({
objects: [{ customId: 'obj1' }],
customVariableRefs: [
{ objectId: 'obj1', variableId: 'var1', variableName: 'var1' },
{ objectId: 'obj2', variableId: 'var2', variableName: 'var2' },
],
});
(mockCanvas.toObject as Mock).mockReturnValue(mockSchema);
renderHook(() =>
useCanvasChange({
canvas: mockCanvas,
onChange: mockOnChange,
schema: mockSchema,
variables: mockVariables,
}),
);
// 获取 object:removed 事件的回调函数
const removedCallback = (mockCanvas.on as any).mock.calls.find(
(call: [string, Function]) => call[0] === 'object:removed',
)?.[1];
// 模拟删除事件
removedCallback?.();
// 验证只保留了存在对象的引用
expect(mockOnChange).toHaveBeenCalledWith({
...mockSchema,
customVariableRefs: [
{ objectId: 'obj1', variableId: 'var1', variableName: 'var1' },
],
});
});
it('应该正确添加图片类型的变量引用', async () => {
const mockCanvas = createMockCanvas();
const mockElement = createMockObject({ customId: 'img1' });
vi.mocked(createElement).mockResolvedValue(mockElement);
const { result } = renderHook(() =>
useCanvasChange({
canvas: mockCanvas,
schema: createMockSchema(),
}),
);
await act(async () => {
await result.current.addRefObjectByVariable({
id: 'var1',
name: 'image1',
type: ViewVariableType.Image,
index: 0,
});
});
expect(createElement).toHaveBeenCalledWith(
expect.objectContaining({
mode: Mode.IMAGE,
elementProps: expect.objectContaining({
editable: false,
src: expect.stringContaining('img-placeholder.png'),
}),
}),
);
expect(mockCanvas.add).toHaveBeenCalledWith(mockElement);
expect(mockCanvas.setActiveObject).toHaveBeenCalledWith(mockElement);
});
it('应该正确添加文本类型的变量引用', async () => {
const mockCanvas = createMockCanvas();
const mockElement = createMockObject({ customId: 'text1' });
vi.mocked(createElement).mockResolvedValue(mockElement);
const { result } = renderHook(() =>
useCanvasChange({
canvas: mockCanvas,
schema: createMockSchema(),
}),
);
await act(async () => {
await result.current.addRefObjectByVariable({
id: 'var1',
name: 'text1',
type: ViewVariableType.String,
index: 0,
});
});
expect(mockCanvas.add).toHaveBeenCalledWith(mockElement);
expect(mockCanvas.setActiveObject).toHaveBeenCalledWith(mockElement);
});
it('应该正确更新对象的引用关系', () => {
const mockCanvas = createMockCanvas();
const mockVariables = [
{ id: 'var1', name: 'var1', type: ViewVariableType.String, index: 0 },
{ id: 'var2', name: 'var2', type: ViewVariableType.String, index: 1 },
{ id: 'var3', name: 'var3', type: ViewVariableType.String, index: 2 },
];
const initialRefs = [
{ objectId: 'obj1', variableId: 'var1', variableName: 'var1' },
];
const mockSchema = createMockSchema({ customVariableRefs: initialRefs });
(mockCanvas.toObject as Mock).mockReturnValue({
...mockSchema,
customVariableRefs: [
{ objectId: 'obj2', variableId: 'var3', variableName: 'var3' },
],
});
const { result } = renderHook(() =>
useCanvasChange({
canvas: mockCanvas,
schema: createMockSchema({ customVariableRefs: initialRefs }),
variables: mockVariables,
}),
);
// 更新已存在的引用
act(() => {
result.current.updateRefByObjectId({
objectId: 'obj1',
variable: {
id: 'var2',
name: 'var2',
type: ViewVariableType.String,
index: 0,
},
});
});
// 添加新的引用
act(() => {
result.current.updateRefByObjectId({
objectId: 'obj2',
variable: {
id: 'var3',
name: 'var3',
type: ViewVariableType.String,
index: 0,
},
});
});
// 删除引用
act(() => {
result.current.updateRefByObjectId({
objectId: 'obj1',
});
});
// 验证最终的引用关系
expect(mockCanvas.toObject().customVariableRefs).toEqual([
{ objectId: 'obj2', variableId: 'var3', variableName: 'var3' },
]);
});
});

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { type Canvas, Rect } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import type { FabricSchema } from '../../src/typings';
import { Mode } from '../../src/typings';
import { useCanvasClip } from '../../src/hooks/use-canvas-clip';
// Mock fabric
vi.mock('fabric', () => ({
Rect: vi.fn(),
}));
describe('useCanvasClip', () => {
const createMockCanvas = () => {
const mockCanvas = {
clipPath: undefined,
requestRenderAll: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
const createMockSchema = (): FabricSchema => ({
width: 800,
height: 600,
background: '#ffffff',
backgroundColor: '#ffffff',
objects: [],
customVariableRefs: [],
customType: Mode.RECT,
customId: 'test-canvas',
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('addClip', () => {
it('应该在 canvas 为 undefined 时不执行任何操作', () => {
const schema = createMockSchema();
const { result } = renderHook(() =>
useCanvasClip({ canvas: undefined, schema }),
);
act(() => {
result.current.addClip();
});
expect(Rect).not.toHaveBeenCalled();
});
it('应该正确添加裁剪区域', () => {
const mockCanvas = createMockCanvas();
const schema = createMockSchema();
const mockRect = {};
(Rect as any).mockReturnValue(mockRect);
const { result } = renderHook(() =>
useCanvasClip({ canvas: mockCanvas, schema }),
);
act(() => {
result.current.addClip();
});
expect(Rect).toHaveBeenCalledWith({
absolutePositioned: true,
top: 0,
left: 0,
width: schema.width,
height: schema.height,
});
expect(mockCanvas.clipPath).toBe(mockRect);
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('removeClip', () => {
it('应该在 canvas 为 undefined 时不执行任何操作', () => {
const schema = createMockSchema();
const { result } = renderHook(() =>
useCanvasClip({ canvas: undefined, schema }),
);
act(() => {
result.current.removeClip();
});
});
it('应该正确移除裁剪区域', () => {
const mockCanvas = createMockCanvas();
const schema = createMockSchema();
const { result } = renderHook(() =>
useCanvasClip({ canvas: mockCanvas, schema }),
);
// 先添加裁剪区域
act(() => {
result.current.addClip();
});
// 移除裁剪区域
act(() => {
result.current.removeClip();
});
expect(mockCanvas.clipPath).toBeUndefined();
expect(mockCanvas.requestRenderAll).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,168 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type Canvas } from 'fabric';
import { renderHook } from '@testing-library/react';
import { useCanvasResize } from '../../src/hooks/use-canvas-resize';
describe('useCanvasResize', () => {
const mockCanvas = {
setDimensions: vi.fn(),
} as unknown as Canvas;
beforeEach(() => {
vi.clearAllMocks();
});
it('should calculate correct scale', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 1000,
maxHeight: 800,
width: 500,
height: 500,
}),
);
expect(result.current.scale).toBe(1.6);
});
it('should calculate scale based on width constraint', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 800,
maxHeight: 1000,
width: 1000,
height: 500,
}),
);
expect(result.current.scale).toBe(0.8);
});
it('should calculate scale based on height constraint', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 1000,
maxHeight: 800,
width: 500,
height: 1000,
}),
);
expect(result.current.scale).toBe(0.8);
});
it('should not resize canvas when maxWidth is 0', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 0,
maxHeight: 800,
width: 500,
height: 500,
}),
);
result.current.resize(mockCanvas);
expect(mockCanvas.setDimensions).not.toHaveBeenCalled();
});
it('should not resize canvas when maxHeight is 0', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 1000,
maxHeight: 0,
width: 500,
height: 500,
}),
);
result.current.resize(mockCanvas);
expect(mockCanvas.setDimensions).not.toHaveBeenCalled();
});
it('should not resize canvas when canvas is undefined', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 1000,
maxHeight: 800,
width: 500,
height: 500,
}),
);
result.current.resize(undefined);
expect(mockCanvas.setDimensions).not.toHaveBeenCalled();
});
it('should set canvas dimensions correctly', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 1000,
maxHeight: 800,
width: 500,
height: 500,
}),
);
result.current.resize(mockCanvas);
// Check base dimensions
expect(mockCanvas.setDimensions).toHaveBeenNthCalledWith(1, {
width: 500,
height: 500,
});
// Check CSS dimensions
expect(mockCanvas.setDimensions).toHaveBeenNthCalledWith(
2,
{
width: '800px',
height: '800px',
},
{
cssOnly: true,
},
);
});
it('should maintain aspect ratio when resizing', () => {
const { result } = renderHook(() =>
useCanvasResize({
maxWidth: 1000,
maxHeight: 800,
width: 1000,
height: 500,
}),
);
result.current.resize(mockCanvas);
// Check CSS dimensions maintain aspect ratio
expect(mockCanvas.setDimensions).toHaveBeenNthCalledWith(
2,
{
width: '1000px',
height: '500px',
},
{
cssOnly: true,
},
);
});
});

View File

@@ -0,0 +1,283 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { type Canvas, type FabricObject } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import { useCommonOperation } from '../../src/hooks/use-common-operation';
describe('useCommonOperation', () => {
const createMockCanvas = () => {
const mockCanvas = {
getActiveObject: vi.fn(),
getActiveObjects: vi.fn(),
discardActiveObject: vi.fn(),
requestRenderAll: vi.fn(),
remove: vi.fn(),
bringObjectToFront: vi.fn(),
sendObjectToBack: vi.fn(),
bringObjectForward: vi.fn(),
sendObjectBackwards: vi.fn(),
setWidth: vi.fn(),
setHeight: vi.fn(),
fire: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
const createMockObject = (props = {}) => {
const mockObject = {
isType: vi.fn(),
fire: vi.fn(),
set: vi.fn(),
left: 100,
top: 100,
...props,
};
return mockObject as unknown as FabricObject;
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('moveActiveObject', () => {
it('应该在 canvas 为 undefined 时不执行任何操作', () => {
const { result } = renderHook(() =>
useCommonOperation({ canvas: undefined }),
);
act(() => {
result.current.moveActiveObject('left');
});
});
it('应该正确移动单个对象', () => {
const mockCanvas = createMockCanvas();
const mockObject = createMockObject();
(mockObject.isType as any).mockReturnValue(false);
(mockCanvas.getActiveObject as any).mockReturnValue(mockObject);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.moveActiveObject('left', 10);
});
expect(mockObject.set).toHaveBeenCalledWith({ left: 90 });
expect(mockObject.fire).toHaveBeenCalledWith('moving', {
target: mockObject,
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该正确移动选中组', () => {
const mockCanvas = createMockCanvas();
const mockObject = createMockObject();
(mockObject.isType as any).mockReturnValue(true);
(mockCanvas.getActiveObject as any).mockReturnValue(mockObject);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.moveActiveObject('right', 10);
});
expect(mockObject.set).toHaveBeenCalledWith({ left: 110 });
expect(mockCanvas.fire).toHaveBeenCalledWith('object:moving', {
target: mockObject,
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('discardActiveObject', () => {
it('应该正确取消选中对象', () => {
const mockCanvas = createMockCanvas();
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.discardActiveObject();
});
expect(mockCanvas.discardActiveObject).toHaveBeenCalled();
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('removeActiveObjects', () => {
it('应该正确移除选中的对象', () => {
const mockCanvas = createMockCanvas();
const mockObjects = [createMockObject(), createMockObject()];
(mockCanvas.getActiveObjects as any).mockReturnValue(mockObjects);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.removeActiveObjects();
});
mockObjects.forEach(obj => {
expect(mockCanvas.remove).toHaveBeenCalledWith(obj);
});
expect(mockCanvas.discardActiveObject).toHaveBeenCalled();
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('moveTo', () => {
it('应该正确将对象移动到最前', () => {
const mockCanvas = createMockCanvas();
const mockObjects = [createMockObject(), createMockObject()];
(mockCanvas.getActiveObjects as any).mockReturnValue(mockObjects);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.moveTo('front');
});
mockObjects.forEach(obj => {
expect(mockCanvas.bringObjectToFront).toHaveBeenCalledWith(obj);
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified-zIndex');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该正确将对象移动到最后', () => {
const mockCanvas = createMockCanvas();
const mockObjects = [createMockObject(), createMockObject()];
(mockCanvas.getActiveObjects as any).mockReturnValue(mockObjects);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.moveTo('backend');
});
mockObjects.forEach(obj => {
expect(mockCanvas.sendObjectToBack).toHaveBeenCalledWith(obj);
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified-zIndex');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该正确将对象向前移动一层', () => {
const mockCanvas = createMockCanvas();
const mockObjects = [createMockObject(), createMockObject()];
(mockCanvas.getActiveObjects as any).mockReturnValue(mockObjects);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.moveTo('front-one');
});
mockObjects.forEach(obj => {
expect(mockCanvas.bringObjectForward).toHaveBeenCalledWith(obj);
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified-zIndex');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该正确将对象向后移动一层', () => {
const mockCanvas = createMockCanvas();
const mockObjects = [createMockObject(), createMockObject()];
(mockCanvas.getActiveObjects as any).mockReturnValue(mockObjects);
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.moveTo('backend-one');
});
mockObjects.forEach(obj => {
expect(mockCanvas.sendObjectBackwards).toHaveBeenCalledWith(obj);
});
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified-zIndex');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
describe('resetWidthHeight', () => {
it('应该正确重置画布宽度', () => {
const mockCanvas = createMockCanvas();
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.resetWidthHeight({ width: 800 });
});
expect(mockCanvas.setWidth).toHaveBeenCalledWith(800);
expect(mockCanvas.setHeight).not.toHaveBeenCalled();
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该正确重置画布高度', () => {
const mockCanvas = createMockCanvas();
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.resetWidthHeight({ height: 600 });
});
expect(mockCanvas.setWidth).not.toHaveBeenCalled();
expect(mockCanvas.setHeight).toHaveBeenCalledWith(600);
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
it('应该正确同时重置画布宽度和高度', () => {
const mockCanvas = createMockCanvas();
const { result } = renderHook(() =>
useCommonOperation({ canvas: mockCanvas }),
);
act(() => {
result.current.resetWidthHeight({ width: 800, height: 600 });
});
expect(mockCanvas.setWidth).toHaveBeenCalledWith(800);
expect(mockCanvas.setHeight).toHaveBeenCalledWith(600);
expect(mockCanvas.fire).toHaveBeenCalledWith('object:modified');
expect(mockCanvas.requestRenderAll).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,418 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { Mode, type FabricSchema } from '../../src/typings';
import { useViewport } from '../../src/hooks/use-viewport';
import { useSnapMove } from '../../src/hooks/use-snap-move';
import { useRedoUndo } from '../../src/hooks/use-redo-undo';
import { usePosition } from '../../src/hooks/use-position';
import { useMousePosition } from '../../src/hooks/use-mouse-position';
import { useInlineTextAdd } from '../../src/hooks/use-inline-text-add';
import { useInitCanvas } from '../../src/hooks/use-init-canvas';
import { useImagAdd } from '../../src/hooks/use-img-add';
import { useGroup } from '../../src/hooks/use-group';
import { useFreePencil } from '../../src/hooks/use-free-pencil';
import { useFabricEditor } from '../../src/hooks/use-fabric-editor';
import { useDragAdd } from '../../src/hooks/use-drag-add';
import { useCopyPaste } from '../../src/hooks/use-copy-paste';
import { useCommonOperation } from '../../src/hooks/use-common-operation';
import { useCanvasResize } from '../../src/hooks/use-canvas-resize';
import { useCanvasChange } from '../../src/hooks/use-canvas-change';
import { useBackground } from '../../src/hooks/use-background';
import { useAlign } from '../../src/hooks/use-align';
import { useActiveObjectChange } from '../../src/hooks/use-active-object-change';
// Mock all dependencies
vi.mock('../../src/hooks/use-canvas-resize', () => ({
useCanvasResize: vi.fn(),
}));
vi.mock('../../src/hooks/use-init-canvas', () => ({
useInitCanvas: vi.fn(),
}));
vi.mock('../../src/hooks/use-viewport', () => ({
useViewport: vi.fn(),
}));
vi.mock('../../src/hooks/use-mouse-position', () => ({
useMousePosition: vi.fn(),
}));
vi.mock('../../src/hooks/use-group', () => ({
useGroup: vi.fn(),
}));
vi.mock('../../src/hooks/use-canvas-change', () => ({
useCanvasChange: vi.fn(),
}));
vi.mock('../../src/hooks/use-snap-move', () => ({
useSnapMove: vi.fn(),
}));
vi.mock('../../src/hooks/use-copy-paste', () => ({
useCopyPaste: vi.fn(),
}));
vi.mock('../../src/hooks/use-redo-undo', () => ({
useRedoUndo: vi.fn(),
}));
vi.mock('../../src/hooks/use-active-object-change', () => ({
useActiveObjectChange: vi.fn(),
}));
vi.mock('../../src/hooks/use-align', () => ({
useAlign: vi.fn(),
}));
vi.mock('../../src/hooks/use-background', () => ({
useBackground: vi.fn(),
}));
vi.mock('../../src/hooks/use-common-operation', () => ({
useCommonOperation: vi.fn(),
}));
vi.mock('../../src/hooks/use-img-add', () => ({
useImagAdd: vi.fn(),
}));
vi.mock('../../src/hooks/use-drag-add', () => ({
useDragAdd: vi.fn(),
}));
vi.mock('../../src/hooks/use-free-pencil', () => ({
useFreePencil: vi.fn(),
}));
vi.mock('../../src/hooks/use-inline-text-add', () => ({
useInlineTextAdd: vi.fn(),
}));
vi.mock('../../src/hooks/use-position', () => ({
usePosition: vi.fn(),
}));
describe('useFabricEditor', () => {
const mockRef = { current: document.createElement('canvas') };
const mockSchema = {
width: 800,
height: 600,
background: '#ffffff',
objects: [],
customVariableRefs: [],
customType: Mode.RECT,
customId: 'test-canvas',
};
const mockMaxWidth = 1000;
const mockMaxHeight = 800;
const mockHelpLineLayerId = 'help-line-layer';
beforeEach(() => {
vi.clearAllMocks();
// Setup default mock returns
(useCanvasResize as any).mockReturnValue({
resize: vi.fn(),
scale: 1,
});
(useInitCanvas as any).mockReturnValue({
canvas: null,
loadFromJSON: vi.fn(),
});
(useViewport as any).mockReturnValue({
viewport: { zoom: 1 },
setViewport: vi.fn(),
zoomToPoint: vi.fn(),
});
(useMousePosition as any).mockReturnValue({
mousePosition: { x: 0, y: 0 },
});
(useGroup as any).mockReturnValue({
group: vi.fn(),
unGroup: vi.fn(),
});
(useCanvasChange as any).mockReturnValue({
startListen: vi.fn(),
stopListen: vi.fn(),
addRefObjectByVariable: vi.fn(),
updateRefByObjectId: vi.fn(),
customVariableRefs: [],
});
(useSnapMove as any).mockImplementation(() => undefined);
(useCopyPaste as any).mockReturnValue({
copy: vi.fn(),
paste: vi.fn(),
disabledPaste: false,
});
(useRedoUndo as any).mockReturnValue({
pushOperation: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
disabledRedo: false,
disabledUndo: false,
redoUndoing: false,
});
(useActiveObjectChange as any).mockReturnValue({
activeObjects: [],
activeObjectsPopPosition: null,
setActiveObjectsProps: vi.fn(),
isActiveObjectsInBack: false,
isActiveObjectsInFront: false,
});
(useAlign as any).mockReturnValue({
alignLeft: vi.fn(),
alignRight: vi.fn(),
alignCenter: vi.fn(),
alignTop: vi.fn(),
alignBottom: vi.fn(),
alignMiddle: vi.fn(),
verticalAverage: vi.fn(),
horizontalAverage: vi.fn(),
});
(useBackground as any).mockReturnValue({
backgroundColor: '#ffffff',
setBackgroundColor: vi.fn(),
});
(useCommonOperation as any).mockReturnValue({
moveActiveObject: vi.fn(),
removeActiveObjects: vi.fn(),
moveTo: vi.fn(),
discardActiveObject: vi.fn(),
resetWidthHeight: vi.fn(),
});
(useImagAdd as any).mockReturnValue({
addImage: vi.fn(),
});
(useDragAdd as any).mockReturnValue({
enterDragAddElement: vi.fn(),
exitDragAddElement: vi.fn(),
});
(useFreePencil as any).mockReturnValue({
enterFreePencil: vi.fn(),
exitFreePencil: vi.fn(),
});
(useInlineTextAdd as any).mockReturnValue({
enterAddInlineText: vi.fn(),
exitAddInlineText: vi.fn(),
});
(usePosition as any).mockReturnValue({
allObjectsPositionInScreen: [],
});
});
it('should initialize with correct parameters', () => {
renderHook(() =>
useFabricEditor({
ref: mockRef,
schema: mockSchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
helpLineLayerId: mockHelpLineLayerId,
}),
);
// Verify useCanvasResize was called with correct params
expect(useCanvasResize).toHaveBeenCalledWith({
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
width: mockSchema.width,
height: mockSchema.height,
});
// Verify useInitCanvas was called with correct params
expect(useInitCanvas).toHaveBeenCalledWith({
ref: mockRef.current,
schema: mockSchema,
startInit: true,
readonly: false,
resize: expect.any(Function),
scale: 1,
onClick: undefined,
});
});
it('should handle schema with legacy customVariableName', () => {
const legacySchema = {
...mockSchema,
objects: [
{
customId: 'variable-img-123',
customType: Mode.IMAGE,
customVariableName: 'testVar',
},
{
customId: 'variable-text-456',
customType: Mode.INLINE_TEXT,
customVariableName: 'testVar2',
},
],
};
renderHook(() =>
useFabricEditor({
ref: mockRef,
schema: legacySchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
helpLineLayerId: mockHelpLineLayerId,
}),
);
// Verify useInitCanvas was called with correct params
expect(useInitCanvas).toHaveBeenCalledWith(
expect.objectContaining({
ref: mockRef.current,
startInit: true,
readonly: false,
scale: 1,
onClick: undefined,
schema: expect.objectContaining({
objects: legacySchema.objects,
}),
}),
);
});
it('should enforce object limit', () => {
const schemaWithObjects = {
...mockSchema,
objects: Array(50).fill({
customId: 'test',
customType: Mode.RECT,
}),
};
const { result } = renderHook(() =>
useFabricEditor({
ref: mockRef,
schema: schemaWithObjects,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
helpLineLayerId: mockHelpLineLayerId,
}),
);
expect(result.current.state.couldAddNewObject).toBe(false);
});
it('should return all required functions and states', () => {
const { result } = renderHook(() =>
useFabricEditor({
ref: mockRef,
schema: mockSchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
helpLineLayerId: mockHelpLineLayerId,
}),
);
// Verify the returned object structure
expect(result.current).toEqual({
canvas: null,
canvasSettings: {
width: mockSchema.width,
height: mockSchema.height,
backgroundColor: '#ffffff',
},
sdk: expect.any(Object),
state: expect.objectContaining({
viewport: { zoom: 1 },
cssScale: 1,
activeObjects: [],
activeObjectsPopPosition: null,
couldAddNewObject: true,
disabledPaste: false,
disabledRedo: false,
disabledUndo: false,
redoUndoing: false,
isActiveObjectsInBack: false,
isActiveObjectsInFront: false,
allObjectsPositionInScreen: [],
canvasWidth: undefined,
canvasHeight: undefined,
customVariableRefs: [],
objectLength: 0,
}),
});
});
it('should call onChange when canvas changes', () => {
const onChange = vi.fn();
const mockJson = { ...mockSchema };
let changeCallback: ((json: FabricSchema) => void) | undefined;
(useCanvasChange as any).mockImplementation(
({
onChange: onChangeCallback,
}: {
onChange: (json: FabricSchema) => void;
}) => {
changeCallback = onChangeCallback;
return {
startListen: vi.fn(),
stopListen: vi.fn(),
addRefObjectByVariable: vi.fn(),
updateRefByObjectId: vi.fn(),
customVariableRefs: [],
};
},
);
renderHook(() =>
useFabricEditor({
ref: mockRef,
schema: mockSchema,
onChange,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
helpLineLayerId: mockHelpLineLayerId,
}),
);
if (changeCallback) {
changeCallback(mockJson);
expect(onChange).toHaveBeenCalledWith(mockJson);
}
});
});

View File

@@ -0,0 +1,169 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { Mode } from '../../src/typings';
import { useSchemaChange } from '../../src/hooks/use-schema-change';
import { useInitCanvas } from '../../src/hooks/use-init-canvas';
import { useFabricPreview } from '../../src/hooks/use-fabric-preview';
import { useCanvasResize } from '../../src/hooks/use-canvas-resize';
// Mock dependencies
vi.mock('../../src/hooks/use-canvas-resize', () => ({
useCanvasResize: vi.fn(),
}));
vi.mock('../../src/hooks/use-init-canvas', () => ({
useInitCanvas: vi.fn(),
}));
vi.mock('../../src/hooks/use-schema-change', () => ({
useSchemaChange: vi.fn(),
}));
describe('useFabricPreview', () => {
const mockRef = { current: document.createElement('canvas') };
const mockSchema = {
width: 800,
height: 600,
background: '#ffffff',
objects: [],
customVariableRefs: [],
customType: Mode.RECT,
customId: 'test-canvas',
};
const mockMaxWidth = 1000;
const mockMaxHeight = 800;
beforeEach(() => {
vi.clearAllMocks();
// Setup default mock returns
(useCanvasResize as any).mockReturnValue({
resize: vi.fn(),
scale: 1,
});
(useInitCanvas as any).mockReturnValue({
canvas: null,
});
(useSchemaChange as any).mockImplementation(() => undefined);
});
it('should initialize with correct parameters', () => {
renderHook(() =>
useFabricPreview({
ref: mockRef,
schema: mockSchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
}),
);
// Verify useCanvasResize was called with correct params
expect(useCanvasResize).toHaveBeenCalledWith({
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
width: mockSchema.width,
height: mockSchema.height,
});
// Verify useInitCanvas was called with correct params
expect(useInitCanvas).toHaveBeenCalledWith({
ref: mockRef.current,
schema: mockSchema,
startInit: true,
readonly: true,
resize: expect.any(Function),
scale: 1,
});
});
it('should call resize when canvas is available', () => {
const mockResize = vi.fn();
const mockCanvas = {};
(useCanvasResize as any).mockReturnValue({
resize: mockResize,
scale: 1,
});
(useInitCanvas as any).mockReturnValue({
canvas: mockCanvas,
});
renderHook(() =>
useFabricPreview({
ref: mockRef,
schema: mockSchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
}),
);
expect(mockResize).toHaveBeenCalledWith(mockCanvas);
});
it('should return correct state with cssScale', () => {
const mockScale = 0.5;
(useCanvasResize as any).mockReturnValue({
resize: vi.fn(),
scale: mockScale,
});
const { result } = renderHook(() =>
useFabricPreview({
ref: mockRef,
schema: mockSchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
}),
);
expect(result.current.state).toEqual({
cssScale: mockScale,
});
});
it('should call useSchemaChange with correct params', () => {
const mockCanvas = {};
(useInitCanvas as any).mockReturnValue({
canvas: mockCanvas,
});
renderHook(() =>
useFabricPreview({
ref: mockRef,
schema: mockSchema,
maxWidth: mockMaxWidth,
maxHeight: mockMaxHeight,
startInit: true,
}),
);
expect(useSchemaChange).toHaveBeenCalledWith({
canvas: mockCanvas,
schema: mockSchema,
readonly: true,
});
});
});

View File

@@ -0,0 +1,180 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
type Canvas,
type ActiveSelection,
type Group,
type FabricObject,
} from 'fabric';
import { renderHook, act } from '@testing-library/react';
import { createElement } from '../../src/utils';
import { Mode } from '../../src/typings';
import { useGroup } from '../../src/hooks/use-group';
// Mock createElement and isGroupElement
const mockIsGroupElement = vi.fn();
vi.mock('../../src/utils', () => ({
createElement: vi.fn(),
isGroupElement: (obj: unknown) => mockIsGroupElement(obj),
}));
describe('useGroup', () => {
const createMockCanvas = () => {
const mockCanvas = {
getActiveObject: vi.fn(),
add: vi.fn(),
remove: vi.fn(),
setActiveObject: vi.fn(),
discardActiveObject: vi.fn(),
requestRenderAll: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
const createMockGroup = () => {
const group = {
type: 'group',
left: 100,
top: 100,
width: 200,
height: 200,
add: vi.fn(),
remove: vi.fn(),
getObjects: vi.fn(),
// 添加必要的 fabric.Object 属性
noScaleCache: false,
lockMovementX: false,
lockMovementY: false,
lockRotation: false,
scaleX: 1,
scaleY: 1,
angle: 0,
originX: 'left' as const,
originY: 'top' as const,
fill: '',
stroke: '',
strokeWidth: 1,
opacity: 1,
visible: true,
hasControls: true,
hasBorders: true,
selectable: true,
evented: true,
canvas: null,
};
return group as unknown as Group;
};
const createMockActiveSelection = () => {
const selection = {
getObjects: vi.fn(),
left: 100,
top: 100,
width: 200,
height: 200,
};
return selection as unknown as ActiveSelection;
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(createElement).mockReset();
mockIsGroupElement.mockReset();
});
describe('group', () => {
it('应该在没有 canvas 时不执行任何操作', async () => {
const { result } = renderHook(() => useGroup({ canvas: undefined }));
await act(() => result.current.group());
expect(createElement).not.toHaveBeenCalled();
});
it('应该在选中少于两个元素时不执行分组', async () => {
const mockCanvas = createMockCanvas();
const mockActiveSelection = createMockActiveSelection();
const mockGetObjects = vi.fn().mockReturnValue([{ id: '1' }]);
Object.assign(mockActiveSelection, { getObjects: mockGetObjects });
(
mockCanvas.getActiveObject as unknown as ReturnType<typeof vi.fn>
).mockReturnValue(mockActiveSelection);
const { result } = renderHook(() => useGroup({ canvas: mockCanvas }));
await act(() => result.current.group());
expect(createElement).not.toHaveBeenCalled();
expect(mockCanvas.add).not.toHaveBeenCalled();
});
it('应该正确执行分组操作', async () => {
const mockCanvas = createMockCanvas();
const mockActiveSelection = createMockActiveSelection();
const mockObjects = [
{ id: '1', type: 'rect' },
{ id: '2', type: 'circle' },
] as unknown as FabricObject[];
const mockGroupElement = createMockGroup();
const mockGetObjects = vi.fn().mockReturnValue(mockObjects);
Object.assign(mockActiveSelection, { getObjects: mockGetObjects });
(
mockCanvas.getActiveObject as unknown as ReturnType<typeof vi.fn>
).mockReturnValue(mockActiveSelection);
vi.mocked(createElement).mockResolvedValue(mockGroupElement);
const { result } = renderHook(() => useGroup({ canvas: mockCanvas }));
await act(() => result.current.group());
expect(createElement).toHaveBeenCalledWith({
mode: Mode.GROUP,
elementProps: {
left: mockActiveSelection.left,
top: mockActiveSelection.top,
width: mockActiveSelection.width,
height: mockActiveSelection.height,
},
});
expect(mockGroupElement.add).toHaveBeenCalledWith(...mockObjects);
expect(mockCanvas.add).toHaveBeenCalledWith(mockGroupElement);
expect(mockCanvas.setActiveObject).toHaveBeenCalledWith(mockGroupElement);
expect(mockCanvas.remove).toHaveBeenCalledWith(...mockObjects);
});
});
describe('unGroup', () => {
it('应该在没有 canvas 时不执行任何操作', async () => {
const { result } = renderHook(() => useGroup({ canvas: undefined }));
await act(() => result.current.unGroup());
expect(createElement).not.toHaveBeenCalled();
});
it('应该在选中的不是组元素时不执行解组', async () => {
const mockCanvas = createMockCanvas();
(
mockCanvas.getActiveObject as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({ type: 'rect' });
const { result } = renderHook(() => useGroup({ canvas: mockCanvas }));
await act(() => result.current.unGroup());
expect(createElement).not.toHaveBeenCalled();
expect(mockCanvas.add).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,286 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Canvas } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import { loadFontWithSchema, setElementAfterLoad } from '../../src/utils';
import { Mode } from '../../src/typings';
import { useInitCanvas } from '../../src/hooks/use-init-canvas';
// Mock fabric
vi.mock('fabric', () => ({
Canvas: vi.fn(),
}));
// Mock utils
vi.mock('../../src/utils', () => ({
loadFontWithSchema: vi.fn(),
setElementAfterLoad: vi.fn(),
}));
describe('useInitCanvas', () => {
const mockRef = document.createElement('canvas');
const mockSchema = {
width: 800,
height: 600,
background: '#ffffff',
backgroundColor: '#ffffff',
objects: [],
customVariableRefs: [],
customType: Mode.RECT,
customId: 'test-canvas',
};
const mockResize = vi.fn();
const mockLoadFromJSON = vi.fn();
const mockRequestRenderAll = vi.fn();
const mockDispose = vi.fn();
const mockOn = vi.fn(() => () => {
// 清理函数
});
let mockCanvas: any;
beforeEach(() => {
vi.clearAllMocks();
(window as any)._fabric_canvas = undefined;
mockCanvas = {
loadFromJSON: mockLoadFromJSON,
requestRenderAll: mockRequestRenderAll,
dispose: mockDispose,
on: mockOn,
};
// Setup Canvas mock
(Canvas as any).mockImplementation(() => mockCanvas);
// Mock loadFromJSON to resolve immediately
mockLoadFromJSON.mockImplementation((json, callback) => {
callback(null, {});
return Promise.resolve();
});
});
it('should not initialize canvas when startInit is false', () => {
act(() => {
renderHook(() =>
useInitCanvas({
startInit: false,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
);
});
expect(Canvas).not.toHaveBeenCalled();
});
it('should not initialize canvas when ref is null', () => {
act(() => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: null,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
);
});
expect(Canvas).not.toHaveBeenCalled();
});
it('should initialize canvas with correct parameters', async () => {
const scale = 1.5;
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
scale,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(Canvas).toHaveBeenCalledWith(mockRef, {
width: mockSchema.width * scale,
height: mockSchema.height * scale,
backgroundColor: mockSchema.backgroundColor,
selection: true,
preserveObjectStacking: true,
});
});
it('should call resize after canvas initialization', async () => {
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockResize).toHaveBeenCalled();
});
it('should load schema and fonts after initialization', async () => {
mockLoadFromJSON.mockImplementation((json, callback) => {
callback(null, {});
loadFontWithSchema({ schema: mockSchema, canvas: mockCanvas });
return Promise.resolve();
});
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockLoadFromJSON).toHaveBeenCalledWith(
JSON.stringify(mockSchema),
expect.any(Function),
);
expect(loadFontWithSchema).toHaveBeenCalledWith({
schema: mockSchema,
canvas: mockCanvas,
});
});
it('should set canvas in debug mode when not readonly', async () => {
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect((window as any)._fabric_canvas).toBe(mockCanvas);
});
it('should not set canvas in debug mode when readonly', async () => {
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: true,
resize: mockResize,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect((window as any)._fabric_canvas).toBeUndefined();
});
it('should bind click handler when provided', async () => {
const mockOnClick = vi.fn();
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
onClick: mockOnClick,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockOn).toHaveBeenCalledWith('mouse:down', expect.any(Function));
});
it('should dispose canvas on unmount', async () => {
let unmount: () => void;
await act(async () => {
({ unmount } = renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
));
await new Promise(resolve => setTimeout(resolve, 0));
});
await act(async () => {
unmount();
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(mockDispose).toHaveBeenCalled();
});
it('should handle element loading callback', async () => {
const mockElement = { type: 'rect' };
mockLoadFromJSON.mockImplementation((json, callback) => {
callback(null, mockElement);
mockRequestRenderAll();
return Promise.resolve();
});
await act(async () => {
renderHook(() =>
useInitCanvas({
startInit: true,
ref: mockRef,
schema: mockSchema,
readonly: false,
resize: mockResize,
}),
);
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(setElementAfterLoad).toHaveBeenCalledWith({
element: mockElement,
options: { readonly: false },
canvas: expect.any(Object),
});
expect(mockRequestRenderAll).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,149 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { type Canvas } from 'fabric';
import { renderHook, act } from '@testing-library/react';
import { useMousePosition } from '../../src/hooks/use-mouse-position';
describe('useMousePosition', () => {
const createMockCanvas = () => {
const mockCanvas = {
on: vi.fn((event: string, callback: (event: any) => void) =>
// 返回一个清理函数
() => {
mockCanvas.off(event, callback);
},
),
off: vi.fn(),
getScenePoint: vi.fn(e => ({
x: e.clientX,
y: e.clientY,
})),
};
return mockCanvas as unknown as Canvas;
};
beforeEach(() => {
vi.clearAllMocks();
});
it('应该返回初始位置 {left: 0, top: 0}', () => {
const { result } = renderHook(() =>
useMousePosition({ canvas: undefined }),
);
expect(result.current.mousePosition).toEqual({ left: 0, top: 0 });
});
it('应该在没有 canvas 时不设置事件监听', () => {
const mockCanvas = createMockCanvas();
renderHook(() => useMousePosition({ canvas: undefined }));
expect(mockCanvas.on).not.toHaveBeenCalled();
});
it('应该在有 canvas 时设置鼠标移动事件监听', () => {
const mockCanvas = createMockCanvas();
renderHook(() => useMousePosition({ canvas: mockCanvas }));
expect(mockCanvas.on).toHaveBeenCalledWith(
'mouse:move',
expect.any(Function),
);
});
it('应该在鼠标移动时更新位置', () => {
const mockCanvas = createMockCanvas();
let moveCallback: (event: {
e: { clientX: number; clientY: number };
}) => void;
const mockOn = vi.fn();
Object.assign(mockCanvas, { on: mockOn });
mockOn.mockImplementation((event: string, callback: any) => {
if (event === 'mouse:move') {
moveCallback = callback;
}
return () => {
mockCanvas.off(event, callback);
};
});
const { result } = renderHook(() =>
useMousePosition({ canvas: mockCanvas }),
);
// 初始位置
expect(result.current.mousePosition).toEqual({ left: 0, top: 0 });
// 模拟鼠标移动
act(() => {
moveCallback({
e: { clientX: 100, clientY: 200 },
});
});
expect(mockCanvas.getScenePoint).toHaveBeenCalledWith({
clientX: 100,
clientY: 200,
});
expect(result.current.mousePosition).toEqual({ left: 100, top: 200 });
});
it('应该在组件卸载时清理事件监听', () => {
const mockCanvas = createMockCanvas();
const cleanupSpy = vi.fn();
const mockOn = vi.fn().mockReturnValue(cleanupSpy);
Object.assign(mockCanvas, { on: mockOn });
const { unmount } = renderHook(() =>
useMousePosition({ canvas: mockCanvas }),
);
unmount();
expect(cleanupSpy).toHaveBeenCalled();
});
it('应该在 canvas 变化时重新设置事件监听', () => {
const mockCanvas1 = createMockCanvas();
const mockCanvas2 = createMockCanvas();
const cleanupSpy = vi.fn();
const mockOn = vi.fn().mockReturnValue(cleanupSpy);
Object.assign(mockCanvas1, { on: mockOn });
const { rerender } = renderHook(
({ canvas }) => useMousePosition({ canvas }),
{
initialProps: { canvas: mockCanvas1 },
},
);
expect(mockCanvas1.on).toHaveBeenCalledWith(
'mouse:move',
expect.any(Function),
);
// 更新 canvas
rerender({ canvas: mockCanvas2 });
// 应该清理旧的事件监听
expect(cleanupSpy).toHaveBeenCalled();
// 应该设置新的事件监听
expect(mockCanvas2.on).toHaveBeenCalledWith(
'mouse:move',
expect.any(Function),
);
});
});

View File

@@ -0,0 +1,267 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { type Canvas } from 'fabric';
import { renderHook } from '@testing-library/react';
import { createSnap, snap } from '../../src/utils/snap/snap';
import type { SnapService } from '../../src/utils/snap/snap';
import { useSnapMove } from '../../src/hooks/use-snap-move';
// Mock snap utils
vi.mock('../../src/utils/snap/snap', () => ({
createSnap: vi.fn(() => ({
move: vi.fn(),
reset: vi.fn(),
destroy: vi.fn(),
helpline: {
resetScale: vi.fn(),
},
canvas: null,
threshold: 5,
rules: [],
snapX: vi.fn(),
snapY: vi.fn(),
snapPoints: [],
snapLines: [],
snapObjects: [],
snapToObjects: vi.fn(),
snapToPoints: vi.fn(),
})),
snap: {
resetAllObjectsPosition: vi.fn(),
helpline: {
resetScale: vi.fn(),
},
},
}));
describe('useSnapMove', () => {
const createMockCanvas = () => {
const mockCanvas = {
on: vi.fn((event: string, callback: (event: any) => void) =>
// 返回一个清理函数
() => {
mockCanvas.off(event, callback);
},
),
off: vi.fn(),
};
return mockCanvas as unknown as Canvas;
};
beforeEach(() => {
vi.clearAllMocks();
});
it('应该在没有 canvas 时不设置事件监听', () => {
renderHook(() =>
useSnapMove({
canvas: undefined,
helpLineLayerId: 'test-layer',
scale: 1,
}),
);
expect(createSnap).not.toHaveBeenCalled();
});
it('应该正确初始化 snap 功能', () => {
const mockCanvas = createMockCanvas();
renderHook(() =>
useSnapMove({
canvas: mockCanvas,
helpLineLayerId: 'test-layer',
scale: 1,
}),
);
expect(createSnap).toHaveBeenCalledWith(mockCanvas, 'test-layer', 1);
expect(mockCanvas.on).toHaveBeenCalledWith(
'mouse:down',
expect.any(Function),
);
expect(mockCanvas.on).toHaveBeenCalledWith(
'mouse:up',
expect.any(Function),
);
expect(mockCanvas.on).toHaveBeenCalledWith(
'object:moving',
expect.any(Function),
);
});
it('应该在鼠标按下时重置所有对象位置', () => {
const mockCanvas = createMockCanvas();
renderHook(() =>
useSnapMove({
canvas: mockCanvas,
helpLineLayerId: 'test-layer',
scale: 1,
}),
);
// 获取 mouse:down 事件的回调函数
const mouseDownCallback = (mockCanvas.on as any).mock.calls.find(
(call: [string, Function]) => call[0] === 'mouse:down',
)?.[1];
// 模拟事件触发
mouseDownCallback?.({ target: { id: 'test-object' } });
expect(snap.resetAllObjectsPosition).toHaveBeenCalledWith({
id: 'test-object',
});
});
it('应该在鼠标松开时重置 snap', () => {
const mockCanvas = createMockCanvas();
const mockSnap = {
move: vi.fn(),
reset: vi.fn(),
destroy: vi.fn(),
helpline: {
resetScale: vi.fn(),
},
canvas: null,
threshold: 5,
rules: [],
snapX: vi.fn(),
snapY: vi.fn(),
snapPoints: [],
snapLines: [],
snapObjects: [],
snapToObjects: vi.fn(),
snapToPoints: vi.fn(),
} as unknown as SnapService;
vi.mocked(createSnap).mockReturnValue(mockSnap);
renderHook(() =>
useSnapMove({
canvas: mockCanvas,
helpLineLayerId: 'test-layer',
scale: 1,
}),
);
// 获取 mouse:up 事件的回调函数
const mouseUpCallback = (mockCanvas.on as any).mock.calls.find(
(call: [string, Function]) => call[0] === 'mouse:up',
)?.[1];
// 模拟事件触发
mouseUpCallback?.({ target: { id: 'test-object' } });
expect(mockSnap.reset).toHaveBeenCalled();
});
it('应该在对象移动时调用 snap.move', () => {
const mockCanvas = createMockCanvas();
const mockSnap = {
move: vi.fn(),
reset: vi.fn(),
destroy: vi.fn(),
helpline: {
resetScale: vi.fn(),
},
canvas: null,
threshold: 5,
rules: [],
snapX: vi.fn(),
snapY: vi.fn(),
snapPoints: [],
snapLines: [],
snapObjects: [],
snapToObjects: vi.fn(),
snapToPoints: vi.fn(),
} as unknown as SnapService;
vi.mocked(createSnap).mockReturnValue(mockSnap);
renderHook(() =>
useSnapMove({
canvas: mockCanvas,
helpLineLayerId: 'test-layer',
scale: 1,
}),
);
// 获取 object:moving 事件的回调函数
const movingCallback = (mockCanvas.on as any).mock.calls.find(
(call: [string, Function]) => call[0] === 'object:moving',
)?.[1];
// 模拟事件触发
movingCallback?.({ target: { id: 'test-object' } });
expect(mockSnap.move).toHaveBeenCalledWith({ id: 'test-object' });
});
it('应该在组件卸载时销毁 snap', () => {
const mockCanvas = createMockCanvas();
const mockSnap = {
move: vi.fn(),
reset: vi.fn(),
destroy: vi.fn(),
helpline: {
resetScale: vi.fn(),
},
canvas: null,
threshold: 5,
rules: [],
snapX: vi.fn(),
snapY: vi.fn(),
snapPoints: [],
snapLines: [],
snapObjects: [],
snapToObjects: vi.fn(),
snapToPoints: vi.fn(),
} as unknown as SnapService;
vi.mocked(createSnap).mockReturnValue(mockSnap);
const { unmount } = renderHook(() =>
useSnapMove({
canvas: mockCanvas,
helpLineLayerId: 'test-layer',
scale: 1,
}),
);
unmount();
expect(mockSnap.destroy).toHaveBeenCalled();
});
it('应该在 scale 变化时重置辅助线比例', () => {
const mockCanvas = createMockCanvas();
const { rerender } = renderHook(
({ scale }) =>
useSnapMove({
canvas: mockCanvas,
helpLineLayerId: 'test-layer',
scale,
}),
{
initialProps: { scale: 1 },
},
);
// 更新 scale
rerender({ scale: 2 });
expect(snap.helpline.resetScale).toHaveBeenCalledWith(2);
});
});

View File

@@ -0,0 +1,193 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type Canvas, type Point } from 'fabric';
import { renderHook } from '@testing-library/react';
import { setViewport, zoomToPoint } from '../../src/utils';
import { Mode } from '../../src/typings';
import { useViewport } from '../../src/hooks/use-viewport';
// Mock dependencies
vi.mock('../../src/utils', () => ({
setViewport: vi.fn(),
zoomToPoint: vi.fn(),
}));
describe('useViewport', () => {
const mockSchema = {
width: 800,
height: 600,
background: '#ffffff',
backgroundColor: '#ffffff',
objects: [],
customVariableRefs: [],
customType: Mode.RECT,
customId: 'test-canvas',
};
const mockFire = vi.fn();
const mockCanvas = {
fire: mockFire,
} as unknown as Canvas;
const minZoom = 0.3;
const maxZoom = 3;
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize with default viewport', () => {
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
expect(result.current.viewport).toEqual([1, 0, 0, 1, 0, 0]);
});
it('should not set viewport when canvas is undefined', () => {
const { result } = renderHook(() =>
useViewport({
canvas: undefined,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.setViewport([2, 0, 0, 2, 100, 100]);
expect(setViewport).not.toHaveBeenCalled();
});
it('should limit viewport x position to not move beyond left edge', () => {
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.setViewport([2, 0, 0, 2, 100, 0]);
expect(setViewport).toHaveBeenCalledWith({
canvas: mockCanvas,
vpt: [2, 0, 0, 2, 0, 0],
});
});
it('should limit viewport x position to not move beyond right edge', () => {
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.setViewport([2, 0, 0, 2, -2000, 0]);
expect(setViewport).toHaveBeenCalledWith({
canvas: mockCanvas,
vpt: [2, 0, 0, 2, -800, 0],
});
});
it('should limit viewport y position to not move beyond top edge', () => {
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.setViewport([2, 0, 0, 2, 0, 100]);
expect(setViewport).toHaveBeenCalledWith({
canvas: mockCanvas,
vpt: [2, 0, 0, 2, 0, 0],
});
});
it('should limit viewport y position to not move beyond bottom edge', () => {
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.setViewport([2, 0, 0, 2, 0, -2000]);
expect(setViewport).toHaveBeenCalledWith({
canvas: mockCanvas,
vpt: [2, 0, 0, 2, 0, -600],
});
});
it('should fire object:moving event after setting viewport', () => {
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.setViewport([2, 0, 0, 2, 0, 0]);
expect(mockFire).toHaveBeenCalledWith('object:moving');
});
it('should call zoomToPoint with correct parameters', () => {
const mockPoint: Point = { x: 100, y: 100 };
const mockZoomLevel = 2;
const mockVpt = [2, 0, 0, 2, 0, 0];
(zoomToPoint as jest.Mock).mockReturnValue(mockVpt);
const { result } = renderHook(() =>
useViewport({
canvas: mockCanvas,
schema: mockSchema,
minZoom,
maxZoom,
}),
);
result.current.zoomToPoint(mockPoint, mockZoomLevel);
expect(zoomToPoint).toHaveBeenCalledWith({
canvas: mockCanvas,
point: mockPoint,
zoomLevel: mockZoomLevel,
minZoom,
maxZoom,
});
expect(setViewport).toHaveBeenCalledWith({
canvas: mockCanvas,
vpt: mockVpt,
});
});
});