/** * 图标解析(@/utils/iconResolver):字符串 → 组件。 * * 重点是三条边界,它们各自对应一类真实故障: * 1. 名称归一化:`chevron-down` / `chevronDown` / `CHEVRON_DOWN` 都要能解析, * 否则历史数据里换过写法的图标会**静默丢失**; * 2. SVG 源码:lucide 覆盖不到的行业专有图标靠它兜底,但必须校验合法性, * 不能把随便一段文本当 SVG 塞进 innerHTML(那是 XSS / 布局炸掉的口子); * 3. 与侧栏共用同一解析器:菜单管理里能预览出来的图标,侧栏必须也渲染得出来。 */ import { describe, expect, it } from 'vitest' import { getIcon, getIconNames, isValidIconValue, looksLikeSvg, normalizeIconName } from '@/utils/iconResolver' import { getMenuIcon } from '@/layouts/icons' describe('图标名归一化', () => { it('kebab / snake / camel / 大写 都归一成 PascalCase', () => { expect(normalizeIconName('chevron-down')).toBe('ChevronDown') expect(normalizeIconName('chevron_down')).toBe('ChevronDown') expect(normalizeIconName('chevronDown')).toBe('ChevronDown') expect(normalizeIconName('CHEVRON DOWN')).toBe('ChevronDown') }) it('单个词只做首字母大写', () => { expect(normalizeIconName('ship')).toBe('Ship') expect(normalizeIconName('Ship')).toBe('Ship') }) it('空值返回空串(不抛错)', () => { expect(normalizeIconName('')).toBe('') expect(normalizeIconName(' ')).toBe('') }) }) describe('getIcon:解析图标名', () => { it('kebab 写法可解析(与 s_menu 存量数据一致)', () => { expect(getIcon('circle-check')).toBeTruthy() expect(getIcon('file-text')).toBeTruthy() }) it('PascalCase 与 camelCase 同样可解析(历史数据兼容)', () => { expect(getIcon('CircleCheck')).toBeTruthy() expect(getIcon('circleCheck')).toBeTruthy() }) it('lucide: 前缀显式指定也能解析', () => { expect(getIcon('lucide:chevron-down')).toBeTruthy() expect(getIcon('lucide:ship')).toBeTruthy() }) it('白名单时代的 15 个图标全部仍可解析(升级不能弄丢现有菜单的图标)', () => { const legacy = [ 'layout-dashboard', 'file-text', 'database', 'table', 'list-checks', 'circle-check', 'users', 'building-2', 'ship', 'plane', 'container', 'package', 'wallet', 'blocks', 'settings', ] for (const name of legacy) { expect(getIcon(name), `${name} 应能解析`).toBeTruthy() } }) it('白名单外的合法 lucide 图标现在也能解析(本次改造的核心)', () => { expect(getIcon('truck')).toBeTruthy() expect(getIcon('calendar')).toBeTruthy() expect(getIcon('receipt')).toBeTruthy() }) it('空值与无效名返回 null(不抛错)', () => { expect(getIcon('')).toBeNull() expect(getIcon(null)).toBeNull() expect(getIcon(undefined)).toBeNull() expect(getIcon(' ')).toBeNull() expect(getIcon('这根本不是图标名')).toBeNull() }) it('lucide: 前缀匹配不到时返回 null,不退回当 SVG 试', () => { expect(getIcon('lucide:not-a-real-icon-xyz')).toBeNull() }) }) describe('getIcon:SVG 源码', () => { const SVG = '' it('合法 SVG 标记可渲染', () => { expect(getIcon(SVG)).toBeTruthy() }) it('同一段 SVG 返回同一组件实例(否则每次渲染都会卸载重建、图标闪烁)', () => { expect(getIcon(SVG)).toBe(getIcon(SVG)) }) it('非 SVG 的普通文本不渲染(不能把任意文本塞进 innerHTML)', () => { expect(getIcon('
hello
')).toBeNull() expect(getIcon('随便一段文字')).toBeNull() }) it('格式错误的 SVG 不渲染', () => { expect(looksLikeSvg('')).toBe(false) }) }) describe('looksLikeSvg', () => { it('识别合法 SVG 与非法片段', () => { expect(looksLikeSvg('')).toBe(true) expect(looksLikeSvg('
')).toBe(false) expect(looksLikeSvg('')).toBe(false) expect(looksLikeSvg('plain text')).toBe(false) }) }) describe('isValidIconValue(表单校验用)', () => { it('留空视为合法(图标可选)', () => { expect(isValidIconValue('')).toBe(true) expect(isValidIconValue(null)).toBe(true) expect(isValidIconValue(undefined)).toBe(true) }) it('拼错的名字判为非法,让输入当场报错而不是静默不显示', () => { expect(isValidIconValue('truck')).toBe(true) expect(isValidIconValue('trukc')).toBe(false) }) }) describe('侧栏与菜单管理共用同一解析器(曾经的静默丢图标 bug)', () => { it('getMenuIcon 能解析白名单外的图标', () => { // 改造前:侧栏查一张 15 项白名单,填 truck 能存进库但侧栏没有图标 expect(getMenuIcon('truck')).toBeTruthy() expect(getMenuIcon('calendar')).toBeTruthy() }) it('getMenuIcon 与 getIcon 对同一输入给出一致结果', () => { const samples = ['settings', 'circle-check', 'truck', 'lucide:ship', '', 'not-a-real-icon'] for (const name of samples) { expect(getMenuIcon(name), name).toBe(getIcon(name) ?? undefined) } }) it('无法解析时返回 undefined 而不是抛错(菜单照常渲染,只是没图标)', () => { expect(getMenuIcon('不存在')).toBeUndefined() expect(getMenuIcon('')).toBeUndefined() }) }) describe('getIconNames', () => { it('返回按字母序排列的全部 lucide 图标名', () => { const names = getIconNames() expect(names.length).toBeGreaterThan(100) expect(names).toEqual([...names].toSorted()) expect(names).toContain('Ship') }) })