first commit

This commit is contained in:
douboer
2026-01-21 13:22:26 +08:00
commit 24452838a1
28 changed files with 7901 additions and 0 deletions

545
tests/Luopan.test.ts Normal file
View File

@@ -0,0 +1,545 @@
/**
* Luopan 组件单元测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import Luopan from '../src/Luopan.vue';
import { EXAMPLES } from '../src/constants';
describe('Luopan 组件', () => {
describe('基本渲染', () => {
it('应该成功渲染', () => {
const wrapper = mount(Luopan);
expect(wrapper.exists()).toBe(true);
});
it('应该渲染工具栏', () => {
const wrapper = mount(Luopan);
const toolbar = wrapper.find('.toolbar');
expect(toolbar.exists()).toBe(true);
});
it('应该渲染 SVG 容器', () => {
const wrapper = mount(Luopan);
const svg = wrapper.find('svg');
expect(svg.exists()).toBe(true);
});
it('应该使用默认尺寸', () => {
const wrapper = mount(Luopan);
const svg = wrapper.find('svg');
expect(svg.attributes('width')).toBeTruthy();
expect(svg.attributes('height')).toBeTruthy();
});
it('应该使用传入的 size prop', () => {
const customSize = 600;
const wrapper = mount(Luopan, {
props: { size: customSize },
});
const svg = wrapper.find('svg');
expect(svg.attributes('width')).toBe(String(customSize));
expect(svg.attributes('height')).toBe(String(customSize));
});
it('应该设置正确的 viewBox', () => {
const size = 520;
const wrapper = mount(Luopan, {
props: { size },
});
const svg = wrapper.find('svg');
const viewBox = svg.attributes('viewBox');
expect(viewBox).toBe(`${-size / 2} ${-size / 2} ${size} ${size}`);
});
});
describe('示例切换', () => {
it('应该渲染示例切换按钮', () => {
const wrapper = mount(Luopan);
const buttons = wrapper.findAll('.toolbar button:not(.zoom-controls button)');
expect(buttons.length).toBeGreaterThanOrEqual(EXAMPLES.length);
});
it('第一个示例按钮应该默认激活', () => {
const wrapper = mount(Luopan);
const firstButton = wrapper.findAll('.toolbar button')[0];
expect(firstButton.classes()).toContain('active');
});
it('点击示例按钮应该切换示例', async () => {
const wrapper = mount(Luopan);
const buttons = wrapper.findAll('.toolbar button');
// 假设至少有2个示例
if (buttons.length >= 2) {
const secondButton = buttons[1];
await secondButton.trigger('click');
expect(secondButton.classes()).toContain('active');
expect(buttons[0].classes()).not.toContain('active');
}
});
});
describe('辅助线显示', () => {
it('应该有辅助线切换开关', () => {
const wrapper = mount(Luopan);
const checkbox = wrapper.find('input[type="checkbox"]');
expect(checkbox.exists()).toBe(true);
});
it('辅助线应该默认显示', () => {
const wrapper = mount(Luopan);
const checkbox = wrapper.find('input[type="checkbox"]');
expect((checkbox.element as HTMLInputElement).checked).toBe(true);
});
it('切换辅助线开关应该显示/隐藏辅助线', async () => {
const wrapper = mount(Luopan);
const checkbox = wrapper.find('input[type="checkbox"]');
// 默认显示辅助线
let guidesGroup = wrapper.findAll('g').find(g => g.attributes('stroke') === '#111827');
expect(guidesGroup).toBeDefined();
// 取消勾选
await checkbox.setValue(false);
guidesGroup = wrapper.findAll('g').find(g => g.attributes('stroke') === '#111827');
// 辅助线可能被隐藏或不渲染
});
});
describe('文字位置模式', () => {
it('应该有文字位置选择器', () => {
const wrapper = mount(Luopan);
const select = wrapper.find('select');
expect(select.exists()).toBe(true);
});
it('选择器应该有两个选项', () => {
const wrapper = mount(Luopan);
const options = wrapper.findAll('option');
expect(options.length).toBe(2);
});
it('应该能切换文字位置模式', async () => {
const wrapper = mount(Luopan);
const select = wrapper.find('select');
// 切换到形心模式
await select.setValue('centroid');
expect((select.element as HTMLSelectElement).value).toBe('centroid');
// 切换回中点模式
await select.setValue('middle');
expect((select.element as HTMLSelectElement).value).toBe('middle');
});
});
describe('缩放功能', () => {
it('应该渲染缩放控件', () => {
const wrapper = mount(Luopan);
const zoomControls = wrapper.find('.zoom-controls');
expect(zoomControls.exists()).toBe(true);
});
it('应该有放大、缩小和重置按钮', () => {
const wrapper = mount(Luopan);
const buttons = wrapper.findAll('.zoom-controls button');
expect(buttons.length).toBe(3);
});
it('应该显示当前缩放级别', () => {
const wrapper = mount(Luopan);
const zoomLevel = wrapper.find('.zoom-level');
expect(zoomLevel.exists()).toBe(true);
expect(zoomLevel.text()).toContain('%');
});
it('点击放大按钮应该增加缩放', async () => {
const wrapper = mount(Luopan);
const zoomInButton = wrapper.findAll('.zoom-controls button')[1];
const svg = wrapper.find('svg');
const initialTransform = svg.attributes('style');
await zoomInButton.trigger('click');
const newTransform = svg.attributes('style');
expect(newTransform).not.toBe(initialTransform);
});
it('点击缩小按钮应该减少缩放', async () => {
const wrapper = mount(Luopan);
const zoomOutButton = wrapper.findAll('.zoom-controls button')[0];
const svg = wrapper.find('svg');
const initialTransform = svg.attributes('style');
await zoomOutButton.trigger('click');
const newTransform = svg.attributes('style');
expect(newTransform).not.toBe(initialTransform);
});
it('点击重置按钮应该重置缩放和平移', async () => {
const wrapper = mount(Luopan);
const buttons = wrapper.findAll('.zoom-controls button');
const zoomInButton = buttons[1];
const resetButton = buttons[2];
const svg = wrapper.find('svg');
// 先放大
await zoomInButton.trigger('click');
// 然后重置
await resetButton.trigger('click');
const transform = svg.attributes('style');
expect(transform).toContain('scale(1)');
});
it('缩放到达上限时应该禁用放大按钮', async () => {
const wrapper = mount(Luopan);
const zoomInButton = wrapper.findAll('.zoom-controls button')[1];
// 多次点击放大直到达到上限
for (let i = 0; i < 30; i++) {
await zoomInButton.trigger('click');
}
expect(zoomInButton.attributes('disabled')).toBeDefined();
});
it('缩放到达下限时应该禁用缩小按钮', async () => {
const wrapper = mount(Luopan);
const zoomOutButton = wrapper.findAll('.zoom-controls button')[0];
// 多次点击缩小直到达到下限
for (let i = 0; i < 10; i++) {
await zoomOutButton.trigger('click');
}
expect(zoomOutButton.attributes('disabled')).toBeDefined();
});
});
describe('鼠标滚轮缩放', () => {
it('应该监听滚轮事件', () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
expect(container.exists()).toBe(true);
});
it('向下滚动应该缩小', async () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
const svg = wrapper.find('svg');
await container.trigger('wheel', { deltaY: 100 });
// 验证缩放变化
const transform = svg.attributes('style');
expect(transform).toBeTruthy();
});
it('向上滚动应该放大', async () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
const svg = wrapper.find('svg');
await container.trigger('wheel', { deltaY: -100 });
// 验证缩放变化
const transform = svg.attributes('style');
expect(transform).toBeTruthy();
});
});
describe('鼠标拖拽平移', () => {
it('鼠标按下时光标应该变为抓取状态', async () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
const svg = wrapper.find('svg');
// 初始状态
expect(svg.attributes('style')).toContain('cursor: grab');
// 鼠标按下
await container.trigger('mousedown', { clientX: 100, clientY: 100 });
// 应该变为抓取中状态
expect(svg.attributes('style')).toContain('cursor: grabbing');
});
it('鼠标释放时光标应该恢复', async () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
const svg = wrapper.find('svg');
await container.trigger('mousedown', { clientX: 100, clientY: 100 });
await container.trigger('mouseup');
expect(svg.attributes('style')).toContain('cursor: grab');
});
it('拖拽应该改变平移位置', async () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
const svg = wrapper.find('svg');
const initialTransform = svg.attributes('style');
// 模拟拖拽
await container.trigger('mousedown', { clientX: 100, clientY: 100 });
await container.trigger('mousemove', { clientX: 150, clientY: 150 });
await container.trigger('mouseup');
const newTransform = svg.attributes('style');
expect(newTransform).not.toBe(initialTransform);
});
it('鼠标离开时应该停止拖拽', async () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
const svg = wrapper.find('svg');
await container.trigger('mousedown', { clientX: 100, clientY: 100 });
await container.trigger('mouseleave');
expect(svg.attributes('style')).toContain('cursor: grab');
});
});
describe('扇区渲染', () => {
it('应该渲染多个扇区路径', () => {
const wrapper = mount(Luopan);
const sectorPaths = wrapper.findAll('path[fill]').filter(path =>
!path.attributes('d')?.includes('none')
);
expect(sectorPaths.length).toBeGreaterThan(0);
});
it('扇区应该有填充颜色', () => {
const wrapper = mount(Luopan);
const sectorPaths = wrapper.findAll('path[fill]');
sectorPaths.forEach((path) => {
const fill = path.attributes('fill');
expect(fill).toBeTruthy();
});
});
it('扇区应该有边框', () => {
const wrapper = mount(Luopan);
const sectorPaths = wrapper.findAll('path[stroke]');
expect(sectorPaths.length).toBeGreaterThan(0);
});
it('某些扇区应该有内部填色', () => {
const wrapper = mount(Luopan);
// 查找内部填色路径fill-opacity="0.6"
const innerFillPaths = wrapper.findAll('path[fill-opacity]');
expect(innerFillPaths.length).toBeGreaterThanOrEqual(0);
});
});
describe('文字渲染', () => {
it('应该渲染文字标签', () => {
const wrapper = mount(Luopan);
const textElements = wrapper.findAll('text');
expect(textElements.length).toBeGreaterThan(0);
});
it('文字应该使用 textPath', () => {
const wrapper = mount(Luopan);
const textPaths = wrapper.findAll('textPath');
expect(textPaths.length).toBeGreaterThan(0);
});
it('应该在 defs 中定义文字路径', () => {
const wrapper = mount(Luopan);
const defs = wrapper.find('defs');
expect(defs.exists()).toBe(true);
const pathsInDefs = defs.findAll('path');
expect(pathsInDefs.length).toBeGreaterThan(0);
});
it('每个 textPath 应该引用对应的路径 id', () => {
const wrapper = mount(Luopan);
const textPaths = wrapper.findAll('textPath');
textPaths.forEach((textPath) => {
const href = textPath.attributes('href');
expect(href).toBeTruthy();
expect(href?.startsWith('#')).toBe(true);
});
});
it('文字应该有字体大小', () => {
const wrapper = mount(Luopan);
const textElements = wrapper.findAll('text');
textElements.forEach((text) => {
const fontSize = text.attributes('font-size');
expect(fontSize).toBeTruthy();
expect(parseFloat(fontSize!)).toBeGreaterThan(0);
});
});
it('文字应该有填充颜色', () => {
const wrapper = mount(Luopan);
const textElements = wrapper.findAll('text');
textElements.forEach((text) => {
const fill = text.attributes('fill');
expect(fill).toBeTruthy();
});
});
it('文字内容应该非空', () => {
const wrapper = mount(Luopan);
const textPaths = wrapper.findAll('textPath');
textPaths.forEach((textPath) => {
expect(textPath.text().length).toBeGreaterThan(0);
});
});
});
describe('辅助线渲染', () => {
it('显示辅助线时应该渲染圆环', async () => {
const wrapper = mount(Luopan);
const checkbox = wrapper.find('input[type="checkbox"]');
await checkbox.setValue(true);
const circles = wrapper.findAll('circle[fill="none"]');
expect(circles.length).toBeGreaterThan(0);
});
it('显示辅助线时应该渲染径向线', async () => {
const wrapper = mount(Luopan);
const checkbox = wrapper.find('input[type="checkbox"]');
await checkbox.setValue(true);
const lines = wrapper.findAll('line');
// 应该有一些径向线
expect(lines.length).toBeGreaterThan(0);
});
it('显示辅助线时应该渲染形心点', async () => {
const wrapper = mount(Luopan);
const checkbox = wrapper.find('input[type="checkbox"]');
await checkbox.setValue(true);
const centroidCircles = wrapper.findAll('circle[fill="#ef4444"]');
expect(centroidCircles.length).toBeGreaterThan(0);
});
});
describe('说明文本', () => {
it('应该显示说明区域', () => {
const wrapper = mount(Luopan);
const note = wrapper.find('.note');
expect(note.exists()).toBe(true);
});
it('说明应该包含角度约定', () => {
const wrapper = mount(Luopan);
const note = wrapper.find('.note');
expect(note.text()).toContain('角度');
});
it('说明应该包含文字方向', () => {
const wrapper = mount(Luopan);
const note = wrapper.find('.note');
expect(note.text()).toContain('文字');
});
});
describe('背景渲染', () => {
it('应该渲染白色背景矩形', () => {
const wrapper = mount(Luopan);
const bgRect = wrapper.find('rect[fill="white"]');
expect(bgRect.exists()).toBe(true);
});
it('背景应该覆盖整个 SVG 区域', () => {
const size = 520;
const wrapper = mount(Luopan, {
props: { size },
});
const bgRect = wrapper.find('rect[fill="white"]');
expect(bgRect.attributes('width')).toBe(String(size));
expect(bgRect.attributes('height')).toBe(String(size));
});
});
describe('组件样式', () => {
it('主容器应该使用 grid 布局', () => {
const wrapper = mount(Luopan);
const wrap = wrapper.find('.luopan-wrap');
expect(wrap.exists()).toBe(true);
});
it('工具栏按钮应该有样式', () => {
const wrapper = mount(Luopan);
const buttons = wrapper.findAll('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('SVG 容器应该存在', () => {
const wrapper = mount(Luopan);
const container = wrapper.find('.svg-container');
expect(container.exists()).toBe(true);
});
});
describe('边界情况', () => {
it('应该处理极小的尺寸', () => {
const wrapper = mount(Luopan, {
props: { size: 100 },
});
expect(wrapper.exists()).toBe(true);
});
it('应该处理极大的尺寸', () => {
const wrapper = mount(Luopan, {
props: { size: 2000 },
});
expect(wrapper.exists()).toBe(true);
});
it('应该在没有示例时不崩溃', () => {
// 这个测试需要 mock EXAMPLES
const wrapper = mount(Luopan);
expect(wrapper.exists()).toBe(true);
});
});
describe('性能', () => {
it('应该在合理时间内渲染', () => {
const startTime = performance.now();
const wrapper = mount(Luopan);
const endTime = performance.now();
expect(endTime - startTime).toBeLessThan(1000); // 应在1秒内完成
});
it('切换示例应该不会造成内存泄漏', async () => {
const wrapper = mount(Luopan);
const buttons = wrapper.findAll('.toolbar button');
if (buttons.length >= 2) {
// 多次切换示例
for (let i = 0; i < 5; i++) {
await buttons[i % buttons.length].trigger('click');
}
}
expect(wrapper.exists()).toBe(true);
});
});
});