165 lines
5.7 KiB
TypeScript
165 lines
5.7 KiB
TypeScript
/**
|
||
* 图标解析(@/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')
|
||
})
|
||
})
|