This commit is contained in:
oneao committed 2026-10-15 17:31:35 +08:00
1 parent b4498e5350
commit bf9dca19de
47 files changed
+8592 -313

No files matched your search

@@ -0,0 +1,164 @@
/**
* 图标解析(@/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 = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>'
it('合法 SVG 标记可渲染', () => {
expect(getIcon(SVG)).toBeTruthy()
})
it('同一段 SVG 返回同一组件实例(否则每次渲染都会卸载重建、图标闪烁)', () => {
expect(getIcon(SVG)).toBe(getIcon(SVG))
})
it('非 SVG 的普通文本不渲染(不能把任意文本塞进 innerHTML)', () => {
expect(getIcon('<div>hello</div>')).toBeNull()
expect(getIcon('随便一段文字')).toBeNull()
})
it('格式错误的 SVG 不渲染', () => {
expect(looksLikeSvg('<svg><unclosed>')).toBe(false)
})
})
describe('looksLikeSvg', () => {
it('识别合法 SVG 与非法片段', () => {
expect(looksLikeSvg('<svg></svg>')).toBe(true)
expect(looksLikeSvg('<div></div>')).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')
})
})