Files
workspace/code/g3soft-erp/web/tests/utils/iconResolver.spec.ts
T
2026-10-15 17:31:35 +08:00

165 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 图标解析(@/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')
})
})