Files
apes-sidebar/tests/file-icons.spec.tsx
T
Menghuan1918 8722bfb29c fix(file-icons): 适配 v0.18.1 基线——重命名行接入解析器 + 测试与门禁补齐
rebase 到 main(v0.18.1)后的适配层:

- 冲突(EditorHost ×2 / TreePanel / FileTree)按并集解:main 侧
  onPathRenamed/onPathDeleted 与 PR 侧 service prop 并存。
- main 新增的行内重命名行仍用 <VscFile>/<VscFolder>,而本次改动已删除
  VscFile import(合并后 TS2304)——改走 dirRowIcon/fileRowIcon,与树行
  同一条回退链;VscFolder 随之无引用,从 import 移除。
- tests/file-icons.spec.tsx:markerB 换成独立元素类型(<b>),使
  「priority 降序 / 同级按注册序」断言真正可区分(此前两个 marker 都是
  <span>,断言恒真);新增「工厂返回 undefined 则拒绝该级、链继续」与
  「注册/注销触发订阅通知」两例(17 → 19)。
- tests/consumer-types.ts:补齐 FileIconDescriptor / registerFileIcon /
  getFileIcons / matchFileIcon / matchFolderIcon / fileIcon / folderIcon /
  FOLDER_EXT / FOLDER_OPEN_EXT 的编译期门禁。
- README:v0.19.0(未发布)段移到「最近更新」最前(此前被自动合并夹在
  v0.18.1 与 v0.18.0 之间)。
- file-icons.tsx:注释修正——展平后的 Map 是后写覆盖,不是「first match
  wins」;当前无重复扩展名键,仅潜在语义。
2026-09-08 21:14:15 +08:00

232 lines
12 KiB
TypeScript

/**
* Tests for the file-icon feature: the external registration API
* (registerFileIcon — registry lifecycle, reserved folder exts, and the
* authoritative fileIcon/folderIcon resolver chains) and the built-in
* per-extension glyph map (extension normalization, group hits, and the
* generic VscFile fallback).
*/
import { describe, it, expect, vi } from 'vitest'
import type { ReactElement, ReactNode } from 'react'
import { VscFile, VscFileCode, VscFileMedia, VscFolder, VscFolderOpened, VscJson, VscMarkdown } from 'react-icons/vsc'
// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout)
const g = globalThis as Record<string, unknown>
if (g.window === undefined) {
g.window = {
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
innerWidth: 1024,
}
}
if (g.localStorage === undefined) {
g.localStorage = {
getItem: () => null,
setItem: () => {},
}
}
import { createBetterSidebarService, SIDEBAR_FEATURES } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
import { builtinFileIcon, builtinFolderIcon, fallbackFileIcon } from '../src/client/file-icons.tsx'
/** The component type of a rendered icon element (identity of the glyph). */
const glyphOf = (node: ReactNode): unknown => (node as ReactElement).type
/** A marker component standing in for a plugin's registered icon. */
const marker = (): ReactNode => <span data-marker />
/** A second marker with a DISTINCT element type, so `glyphOf` can tell the
* two registrations apart in priority/ordering assertions. */
const markerB = (): ReactNode => <b data-marker-b />
describe('file icon registration API', () => {
it('registerFileIcon adds to the registry and dispose removes it', () => {
const service = createBetterSidebarService(createSidebarStore())
expect(service.getFileIcons()).toHaveLength(0)
const dispose = service.registerFileIcon({ id: 'test:icons', exts: ['csv'], icon: marker })
expect(service.getFileIcons()).toHaveLength(1)
expect(service.matchFileIcon('/w/a.csv')?.id).toBe('test:icons')
dispose()
expect(service.getFileIcons()).toHaveLength(0)
expect(service.matchFileIcon('/w/a.csv')).toBeUndefined()
})
it('registerFileIcon throws on duplicate id', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'dup', exts: [], icon: marker })
expect(() => service.registerFileIcon({ id: 'dup', exts: [], icon: marker })).toThrow()
})
it('matchFileIcon matches by extension (case-insensitive), specifics only', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'csv', exts: ['csv'], icon: marker })
service.registerFileIcon({ id: 'all', exts: [], icon: marker })
expect(service.matchFileIcon('/w/data.csv')?.id).toBe('csv')
expect(service.matchFileIcon('/w/DATA.CSV')?.id).toBe('csv')
// The catch-all never answers matchFileIcon (it lives in fileIcon's chain).
expect(service.matchFileIcon('/w/a.tsv')).toBeUndefined()
})
it('higher priority wins on extension conflict; ties keep registration order', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'low', exts: ['csv'], icon: marker })
service.registerFileIcon({ id: 'high', exts: ['csv'], priority: 10, icon: marker })
service.registerFileIcon({ id: 'tie', exts: ['csv'], icon: marker })
expect(service.matchFileIcon('/w/a.csv')?.id).toBe('high')
const fresh = createBetterSidebarService(createSidebarStore())
fresh.registerFileIcon({ id: 'first', exts: ['csv'], icon: marker })
fresh.registerFileIcon({ id: 'second', exts: ['csv'], icon: marker })
expect(fresh.matchFileIcon('/w/a.csv')?.id).toBe('first')
})
it('the feature is advertised in SIDEBAR_FEATURES', () => {
expect(SIDEBAR_FEATURES.includes('fileIcons')).toBe(true)
})
it('register and dispose notify subscribers (mounted rows re-resolve, no reload)', () => {
const service = createBetterSidebarService(createSidebarStore())
let notified = 0
const unsubscribe = service.subscribe(() => { notified += 1 })
const dispose = service.registerFileIcon({ id: 'test:icons', exts: ['csv'], icon: marker })
expect(notified).toBe(1)
dispose()
expect(notified).toBe(2)
// A second dispose is a no-op — no spurious notification.
dispose()
expect(notified).toBe(2)
unsubscribe()
service.registerFileIcon({ id: 'test:other', exts: ['tsv'], icon: marker })
expect(notified).toBe(2)
})
})
describe('fileIcon resolver chain (specific → builtin → catch-all → VscFile)', () => {
it('a specific registration beats the builtin glyph', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'md', exts: ['md'], icon: marker })
expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe('span')
})
it('the builtin glyph beats a registered catch-all (the default only claims unclaimed extensions)', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'all', exts: [], icon: marker })
expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe(VscMarkdown)
expect(glyphOf(service.fileIcon('/w/logo.png', 14))).toBe(VscFileMedia)
// An extension the builtin map does not cover reaches the catch-all.
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('span')
})
it('the best (priority desc) catch-all wins among several', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'all-a', exts: [], icon: marker })
service.registerFileIcon({ id: 'all-b', exts: [], priority: 5, icon: markerB })
// markerB ('b') is the higher-priority catch-all — not merely "some span".
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('b')
const tie = createBetterSidebarService(createSidebarStore())
tie.registerFileIcon({ id: 'first', exts: [], icon: marker })
tie.registerFileIcon({ id: 'second', exts: [], icon: markerB })
// Equal priority keeps registration order (the first-registered wins).
expect(glyphOf(tie.fileIcon('/w/Makefile', 14))).toBe('span')
})
it('a factory returning undefined declines that link and the chain continues', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'md-decline', exts: ['md'], icon: () => undefined })
// The specific registration declined → the built-in glyph claims it.
expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe(VscMarkdown)
service.registerFileIcon({ id: 'all-decline', exts: [], priority: 10, icon: () => undefined })
service.registerFileIcon({ id: 'all-take', exts: [], icon: marker })
// The declining catch-all is skipped, the next one takes the row.
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('span')
})
it('with no registration at all the chain is the built-in one', () => {
const service = createBetterSidebarService(createSidebarStore())
expect(glyphOf(service.fileIcon('/w/pkg.json', 14))).toBe(VscJson)
expect(glyphOf(service.fileIcon('/w/main.ts', 14))).toBe(VscFileCode)
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe(VscFile)
})
it('a throwing factory is skipped at every level (console.error, next link wins)', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'boom-md', exts: ['md'], icon: () => { throw new Error('boom') } })
// Specific throws → falls to the builtin glyph.
expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe(VscMarkdown)
service.registerFileIcon({ id: 'boom-all', exts: [], priority: 10, icon: () => { throw new Error('boom') } })
// Catch-all throws → falls to the stock VscFile.
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe(VscFile)
expect(errorSpy).toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('reserved folder exts never claim a real file named x.folder', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'folders', exts: ['folder', 'folder-open'], icon: marker })
expect(service.matchFileIcon('/w/x.folder')).toBeUndefined()
// The real file falls through to the stock default, not the folder icon.
expect(glyphOf(service.fileIcon('/w/x.folder', 14))).toBe(VscFile)
})
})
describe('folderIcon resolver (registered folder/folder-open → builtin glyphs)', () => {
it('unregistered directories show the builtin glyphs', () => {
const service = createBetterSidebarService(createSidebarStore())
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(VscFolder)
expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe(VscFolderOpened)
expect(glyphOf(builtinFolderIcon(false, 14))).toBe(VscFolder)
expect(glyphOf(builtinFolderIcon(true, 14))).toBe(VscFolderOpened)
})
it('folder / folder-open registrations replace the dir glyphs (priority desc)', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'closed', exts: ['folder'], icon: marker })
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe('span')
// The OPEN row is a separate reserved ext — still the builtin here.
expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe(VscFolderOpened)
service.registerFileIcon({ id: 'open', exts: ['folder-open'], icon: markerB })
expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe('b')
service.registerFileIcon({ id: 'closed-hi', exts: ['folder'], priority: 3, icon: markerB })
// The higher-priority registration ('b') takes the closed row.
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe('b')
})
it('a catch-all registration never claims a directory', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'all', exts: [], icon: marker })
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(VscFolder)
})
it('a throwing folder factory falls back to the builtin glyph', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'boom', exts: ['folder'], icon: () => { throw new Error('boom') } })
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(VscFolder)
expect(errorSpy).toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
})
describe('built-in glyph map (builtinFileIcon)', () => {
it('maps representative extensions to their group glyph', () => {
expect(glyphOf(builtinFileIcon('/w/README.md', 14))).toBe(VscMarkdown)
expect(glyphOf(builtinFileIcon('/w/logo.png', 14))).toBe(VscFileMedia)
expect(glyphOf(builtinFileIcon('/w/logo.SVG', 14))).toBe(VscFileMedia)
expect(glyphOf(builtinFileIcon('/w/pkg.json', 14))).toBe(VscJson)
expect(glyphOf(builtinFileIcon('/w/main.ts', 14))).toBe(VscFileCode)
expect(glyphOf(builtinFileIcon('/w/main.py', 14))).toBe(VscFileCode)
})
it('falls back to the generic VscFile for unknown extensions and paths without one', () => {
expect(glyphOf(builtinFileIcon('/w/data.xyzunknown', 14))).toBe(VscFile)
expect(glyphOf(builtinFileIcon('/w/Makefile', 14))).toBe(VscFile)
expect(glyphOf(fallbackFileIcon(14))).toBe(VscFile)
})
})