/** * useLuopan 组合函数单元测试 */ import { describe, it, expect } from 'vitest'; import { ref } from 'vue'; import { useLuopan } from '../src/composables/useLuopan'; import type { Example, TextRadialPosition } from '../src/types'; describe('useLuopan', () => { const createMockExample = (): Example => ({ name: '测试示例', angles: [0, 90, 180, 270, 360], radii: [50, 100, 150], }); describe('基本功能', () => { it('应该返回所有必需的属性和方法', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const result = useLuopan(example, textRadialPosition); expect(result).toHaveProperty('anglesDeg'); expect(result).toHaveProperty('rings'); expect(result).toHaveProperty('outerMost'); expect(result).toHaveProperty('sectors'); expect(result).toHaveProperty('getLabelTransform'); expect(result).toHaveProperty('toXY'); }); it('anglesDeg 应该返回角度数组', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { anglesDeg } = useLuopan(example, textRadialPosition); expect(anglesDeg.value).toEqual([0, 90, 180, 270, 360]); }); it('rings 应该返回半径数组', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { rings } = useLuopan(example, textRadialPosition); expect(rings.value).toEqual([50, 100, 150]); }); it('outerMost 应该返回最外层半径', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { outerMost } = useLuopan(example, textRadialPosition); expect(outerMost.value).toBe(150); }); }); describe('扇区生成', () => { it('应该生成正确数量的扇区', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); // 4个角度分割 × 3层 = 12个扇区 expect(sectors.value.length).toBe(12); }); it('每个扇区应该有必需的属性', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); sectors.value.forEach((sector) => { expect(sector).toHaveProperty('key'); expect(sector).toHaveProperty('layerIndex'); expect(sector).toHaveProperty('pieIndex'); expect(sector).toHaveProperty('rInner'); expect(sector).toHaveProperty('rOuter'); expect(sector).toHaveProperty('aStart'); expect(sector).toHaveProperty('aEnd'); expect(sector).toHaveProperty('aMidDeg'); expect(sector).toHaveProperty('aMidRad'); expect(sector).toHaveProperty('cx'); expect(sector).toHaveProperty('cy'); expect(sector).toHaveProperty('fill'); expect(sector).toHaveProperty('textColor'); expect(sector).toHaveProperty('label'); expect(sector).toHaveProperty('path'); expect(sector).toHaveProperty('textPath'); expect(sector).toHaveProperty('textPathId'); expect(sector).toHaveProperty('needReverse'); expect(sector).toHaveProperty('isVertical'); expect(sector).toHaveProperty('fontSize'); }); }); it('扇区的 key 应该是唯一的', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); const keys = sectors.value.map((s) => s.key); const uniqueKeys = new Set(keys); expect(uniqueKeys.size).toBe(keys.length); }); it('扇区应该有正确的层索引和扇区索引', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); // 检查第一层的扇区 const layer0Sectors = sectors.value.filter((s) => s.layerIndex === 0); expect(layer0Sectors.length).toBe(4); expect(layer0Sectors.map((s) => s.pieIndex)).toEqual([0, 1, 2, 3]); // 检查第二层的扇区 const layer1Sectors = sectors.value.filter((s) => s.layerIndex === 1); expect(layer1Sectors.length).toBe(4); expect(layer1Sectors.map((s) => s.pieIndex)).toEqual([0, 1, 2, 3]); }); it('第一层扇区应该从圆心开始(rInner = 0)', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); const layer0Sectors = sectors.value.filter((s) => s.layerIndex === 0); layer0Sectors.forEach((sector) => { expect(sector.rInner).toBe(0); expect(sector.rOuter).toBe(50); }); }); it('扇区应该有正确的角度范围', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); const sector0 = sectors.value.find((s) => s.layerIndex === 0 && s.pieIndex === 0); expect(sector0?.aStart).toBe(0); expect(sector0?.aEnd).toBe(90); const sector1 = sectors.value.find((s) => s.layerIndex === 0 && s.pieIndex === 1); expect(sector1?.aStart).toBe(90); expect(sector1?.aEnd).toBe(180); }); it('扇区应该有有效的颜色', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); sectors.value.forEach((sector) => { expect(sector.fill).toMatch(/^(hsl\(.*\)|#[0-9a-fA-F]{6}|#ffffff)$/); expect(sector.textColor).toMatch(/^(#[0-9a-fA-F]{6}|#ffffff)$/); }); }); it('扇区应该有有效的字体大小', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); sectors.value.forEach((sector) => { expect(sector.fontSize).toBeGreaterThan(0); expect(sector.fontSize).toBeLessThanOrEqual(30); }); }); it('扇区应该有非空的标签', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); sectors.value.forEach((sector) => { expect(sector.label).toBeTruthy(); expect(sector.label.length).toBeGreaterThan(0); }); }); it('扇区应该有有效的 SVG 路径', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); sectors.value.forEach((sector) => { expect(sector.path).toContain('M '); expect(sector.path).toContain('Z'); }); }); it('扇区应该有有效的文字路径', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); sectors.value.forEach((sector) => { expect(sector.textPath).toContain('M '); }); }); }); describe('文字位置模式', () => { it('应该在 middle 模式下使用中点位置', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); // 第二层的中点应该在 (50 + 100) / 2 = 75 附近 const layer1Sector = sectors.value.find((s) => s.layerIndex === 1); expect(layer1Sector).toBeDefined(); }); it('应该在 centroid 模式下使用形心位置', () => { const example = ref(createMockExample()); const textRadialPosition = ref('centroid'); const { sectors } = useLuopan(example, textRadialPosition); // 形心位置应该与中点位置不同 expect(sectors.value.length).toBeGreaterThan(0); }); it('最内层应该始终使用形心位置', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); const innermostSectors = sectors.value.filter((s) => s.layerIndex === 0); expect(innermostSectors.length).toBeGreaterThan(0); // 最内层应该使用形心位置,即使设置为 middle }); }); describe('文字方向', () => { it('应该为左半圆的扇区设置 needReverse', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); // 中间角度在 (90, 270) 范围内的扇区应该需要反向 const leftSectors = sectors.value.filter( (s) => s.aMidDeg > 90 && s.aMidDeg < 270 ); leftSectors.forEach((sector) => { expect(sector.needReverse).toBe(true); }); }); it('应该为右半圆的扇区不设置 needReverse', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); // 中间角度在 [0, 90] 或 [270, 360] 范围内的扇区不需要反向 const rightSectors = sectors.value.filter( (s) => s.aMidDeg <= 90 || s.aMidDeg >= 270 ); rightSectors.forEach((sector) => { expect(sector.needReverse).toBe(false); }); }); }); describe('竖排文字判断', () => { it('应该为窄扇区设置 isVertical', () => { const example = ref({ name: '窄扇区示例', angles: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, 210, 220, 230, 240, 250, 260, 270, 280, 290, 300, 310, 320, 330, 340, 350, 360], radii: [50, 100, 150], }); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); // 某些窄扇区应该被设置为竖排 const verticalSectors = sectors.value.filter((s) => s.isVertical); expect(verticalSectors.length).toBeGreaterThan(0); }); it('应该为宽扇区不设置 isVertical', () => { const example = ref({ name: '宽扇区示例', angles: [0, 180, 360], radii: [50, 60], }); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); // 宽扇区应该都不是竖排 sectors.value.forEach((sector) => { expect(sector.isVertical).toBe(false); }); }); }); describe('内部填色', () => { it('某些扇区应该有内部填色', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); const filledSectors = sectors.value.filter( (s) => s.innerFillPath && s.innerFillColor ); expect(filledSectors.length).toBeGreaterThan(0); }); it('有内部填色的扇区应该使用白色底色和黑色文字', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); const filledSectors = sectors.value.filter( (s) => s.innerFillPath && s.innerFillColor ); filledSectors.forEach((sector) => { expect(sector.fill).toBe('#ffffff'); expect(sector.textColor).toBe('#111827'); }); }); }); describe('响应式更新', () => { it('应该响应示例变化', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); const initialCount = sectors.value.length; // 更改示例 example.value = { name: '新示例', angles: [0, 120, 240, 360], radii: [100, 200], }; // 扇区数量应该变化 expect(sectors.value.length).not.toBe(initialCount); expect(sectors.value.length).toBe(6); // 3个角度分割 × 2层 }); it('应该响应文字位置模式变化', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); const middlePaths = sectors.value.map((s) => s.textPath); // 更改文字位置模式 textRadialPosition.value = 'centroid'; const centroidPaths = sectors.value.map((s) => s.textPath); // 非最内层的文字路径应该有所不同 const differentPaths = middlePaths.filter( (path, index) => path !== centroidPaths[index] ); expect(differentPaths.length).toBeGreaterThan(0); }); }); describe('getLabelTransform', () => { it('应该返回有效的 SVG transform 字符串', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { sectors, getLabelTransform } = useLuopan(example, textRadialPosition); const sector = sectors.value[0]; const transform = getLabelTransform(sector); expect(transform).toContain('translate'); expect(transform).toContain('rotate'); }); }); describe('toXY', () => { it('应该正确转换极坐标', () => { const example = ref(createMockExample()); const textRadialPosition = ref('middle'); const { toXY } = useLuopan(example, textRadialPosition); const point = toXY(0, 100); expect(point.x).toBeCloseTo(0); expect(point.y).toBeCloseTo(-100); }); }); describe('边界情况', () => { it('应该处理单层罗盘', () => { const example = ref({ name: '单层', angles: [0, 90, 180, 270, 360], radii: [100], }); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); expect(sectors.value.length).toBe(4); sectors.value.forEach((sector) => { expect(sector.layerIndex).toBe(0); expect(sector.rInner).toBe(0); expect(sector.rOuter).toBe(100); }); }); it('应该处理两个扇区的罗盘', () => { const example = ref({ name: '两扇区', angles: [0, 180, 360], radii: [100], }); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); expect(sectors.value.length).toBe(2); }); it('应该处理大量层数的罗盘', () => { const example = ref({ name: '多层', angles: [0, 90, 180, 270, 360], radii: [20, 40, 60, 80, 100, 120, 140, 160, 180, 200], }); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); expect(sectors.value.length).toBe(40); // 4个扇区 × 10层 const layerIndices = new Set(sectors.value.map((s) => s.layerIndex)); expect(layerIndices.size).toBe(10); }); it('应该处理大量扇区的罗盘', () => { const example = ref({ name: '多扇区', angles: Array.from({ length: 37 }, (_, i) => i * 10), // 36个扇区 radii: [100, 200], }); const textRadialPosition = ref('middle'); const { sectors } = useLuopan(example, textRadialPosition); expect(sectors.value.length).toBe(72); // 36个扇区 × 2层 }); }); });