Files
apes-sidebar/tests/terminal-font.spec.ts
T
ShelbyTang dd5a3dda07 fix(terminal): 补全 Nerd Font 图标字体回退,修复提示符图标显示为豆腐块
starship / powerlevel10k 等提示符的图标取自 Nerd Font 私有区。Chromium 的
隐式系统字体回退能覆盖 BMP PUA(U+E000–U+F8FF,如 U+E0B0 powerline 分隔符),
但覆盖不到补充平面 PUA-B(U+F0000+)——Nerd Fonts v3 把 Material Design 图标集
迁到了这里,于是这部分图标渲染为豆腐块。经排查用户字体本身含完整字形(format-12
cmap 覆盖上述码点),而 macOS 系统等宽字体(Menlo / SF Mono / Monaco)对这些
码点零覆盖,根因是插件的 fontFamily 栈未显式列出任何 Nerd Font。

修复:新增 withIconFontFallbacks(),在 resolveTerminalFont 出口为胜出的基础
字体追加 Nerd Font 图标族(显式命名可使浏览器逐字符查询,两个平面都能命中)。
基础字体优先级链(用户 pref > 主题 > 内置栈)保持不变。

三条约束:
- 只追加不前置:xterm 用字体栈首项测量单元格宽度,基础字体须留在最前;
- 插入到第一个通用族(monospace 等)之前:通用族是 catch-all,其后的字体
  永不被查询,仅判断尾项会让主题给出的 `ui-monospace, Menlo, monospace, …`
  这类栈静默失效;
- 按族名去重(引号/大小写/空白不敏感),用户自列的字体保持原有优先级。

另外:
- 过滤 CSS 全局关键字(inherit/initial/unset/revert/revert-layer)。主题字体
  取自原始 token 值,皮肤确实会设成这类值,追加后会得到非法 font-family 被
  CSSOM 静默丢弃,导致主题字体与图标字体一起失效,现改为回退到下一级;
- 字体栈按顶层逗号切分并感知引号与括号,避免拆坏 "Foo,Bar" 与 var(--x, y);
- 不列彩色 emoji 字体:本问题的图标全为 PUA 且真 emoji 有独立回退路径,而
  彩色字体会捕获提示符依赖等宽的 BMP 符号(U+26A0、U+2714 等)破坏网格。

单测 12 → 19 例,补齐中间通用族回归守卫、CSS 全局关键字、多通用族、幂等性与
回退顺序契约;全量 594 例通过。
2026-08-18 10:50:30 +08:00

172 lines
8.2 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.
/**
* Terminal font resolution tests: the custom font prefs (side card settings,
* terminal card) resolve into xterm options with a strict fallback chain —
* user family > theme code font > the built-in monospace stack, and the size
* clamped into the 9–32 contract.
*
* On top of the base family, icon fonts (Nerd Font + color emoji) are
* appended so shell prompts drawing from the Private Use Areas render real
* glyphs instead of the missing-glyph box.
*/
import { describe, expect, it } from 'vitest'
import { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../src/prefs-shared.ts'
import {
DEFAULT_TERMINAL_FONT_FAMILY,
ICON_FONT_FALLBACKS,
resolveTerminalFont,
withIconFontFallbacks,
} from '../src/client/terminal-font.ts'
describe('clampTerminalFontSize', () => {
it('rounds and clamps into the 9–32 contract', () => {
expect(clampTerminalFontSize(13)).toBe(13)
expect(clampTerminalFontSize(15.6)).toBe(16)
expect(clampTerminalFontSize(5)).toBe(TERMINAL_FONT_SIZE_MIN)
expect(clampTerminalFontSize(40)).toBe(TERMINAL_FONT_SIZE_MAX)
})
})
describe('withIconFontFallbacks', () => {
/** Index of a family in a resolved stack, -1 when absent. */
function indexOfFamily(stack: string, family: string): number {
return stack.split(',').map(part => part.trim()).indexOf(family)
}
it('keeps the base font first so xterm measures cells against it', () => {
const stack = withIconFontFallbacks('"JetBrains Mono", monospace')
expect(stack.startsWith('"JetBrains Mono"')).toBe(true)
})
it('splices icon fonts ahead of a trailing generic family (a generic always resolves)', () => {
const stack = withIconFontFallbacks('"JetBrains Mono", monospace')
const parts = stack.split(',').map(part => part.trim())
expect(parts[parts.length - 1]).toBe('monospace')
expect(parts.indexOf('"Symbols Nerd Font Mono"')).toBeGreaterThan(0)
expect(parts.indexOf('"Symbols Nerd Font Mono"')).toBeLessThan(parts.length - 1)
})
it('splices ahead of a generic sitting MID-stack, not just a trailing one', () => {
// Regression guard: a generic anywhere is a catch-all, so appending
// after it silently defeats the whole fix (the icon fonts would never
// be consulted). Stacks of this shape come from theme tokens.
const stack = withIconFontFallbacks('ui-monospace, Menlo, monospace, "Apple Color Emoji"')
expect(indexOfFamily(stack, '"Symbols Nerd Font Mono"'))
.toBeLessThan(indexOfFamily(stack, 'ui-monospace'))
})
it('splices ahead of the FIRST generic when several are present', () => {
const stack = withIconFontFallbacks('Menlo, monospace, sans-serif')
const nerd = indexOfFamily(stack, '"Symbols Nerd Font Mono"')
expect(nerd).toBeGreaterThan(indexOfFamily(stack, 'Menlo'))
expect(nerd).toBeLessThan(indexOfFamily(stack, 'monospace'))
expect(indexOfFamily(stack, 'sans-serif')).toBe(stack.split(',').length - 1)
})
it('appends at the end when the stack has no generic family', () => {
const stack = withIconFontFallbacks('"JetBrains Mono"')
expect(stack.startsWith('"JetBrains Mono", "Symbols Nerd Font Mono"')).toBe(true)
})
it('orders symbols-only patches ahead of fully-patched distributions', () => {
// Order IS the priority contract: the symbols-only families carry no
// Latin glyphs, so they can never hijack ASCII cell metrics.
const stack = withIconFontFallbacks('Menlo, monospace')
expect(indexOfFamily(stack, '"Symbols Nerd Font Mono"'))
.toBeLessThan(indexOfFamily(stack, '"Hack Nerd Font Mono"'))
})
it('names Nerd Font families explicitly to cover both PUA planes', () => {
const stack = withIconFontFallbacks('Menlo, monospace')
expect(stack).toContain('"Symbols Nerd Font Mono"')
expect(stack).toContain('"Hack Nerd Font Mono"')
})
it('omits color-emoji families (Chromium routes emoji through its own fallback)', () => {
// Listing a color font ahead of the generic would let it capture BMP
// symbols the prompt expects in monospace and break the cell grid.
const stack = withIconFontFallbacks('Menlo, monospace')
expect(stack).not.toContain('Apple Color Emoji')
expect(stack).not.toContain('Segoe UI Emoji')
expect(stack).not.toContain('Noto Color Emoji')
})
it('does not duplicate a family the caller already listed, preserving its priority', () => {
const stack = withIconFontFallbacks("'hack nerd font mono', monospace")
expect(stack.toLowerCase().split('hack nerd font mono').length - 1).toBe(1)
// The caller's entry keeps pole position.
expect(stack.startsWith("'hack nerd font mono'")).toBe(true)
})
it('treats quote, case and whitespace differences as the same family', () => {
const stack = withIconFontFallbacks('"Symbols Nerd Font Mono", monospace')
expect(stack.toLowerCase().split('symbols').length - 1).toBe(2) // Mono + proportional
})
it('is idempotent so TerminalView\'s option diff does not thrash', () => {
const once = withIconFontFallbacks('Menlo, monospace')
expect(withIconFontFallbacks(once)).toBe(once)
})
it('splits on top-level commas only, keeping quoted and function values whole', () => {
expect(withIconFontFallbacks('"Foo,Bar", monospace')).toContain('"Foo,Bar"')
expect(withIconFontFallbacks('var(--code, monospace)')).toContain('var(--code, monospace)')
})
it('falls back to the icon list alone for an empty stack (defensive)', () => {
expect(withIconFontFallbacks('')).toBe(ICON_FONT_FALLBACKS.join(', '))
expect(withIconFontFallbacks(' , ')).toBe(ICON_FONT_FALLBACKS.join(', '))
})
})
describe('resolveTerminalFont', () => {
it('falls back to the theme code font when the family pref is empty', () => {
const { fontFamily, fontSize } = resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, 'var-theme-font')
// The theme font stays the base (first) entry; icon fonts top it up.
expect(fontFamily).toBe(withIconFontFallbacks('var-theme-font'))
expect(fontFamily.startsWith('var-theme-font')).toBe(true)
expect(fontSize).toBe(13)
})
it('falls back to the built-in monospace stack when neither the pref nor the theme provides one', () => {
expect(resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, undefined).fontFamily)
.toBe(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY))
// A whitespace-only pref counts as empty (theme default).
expect(resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: ' ' }, undefined).fontFamily)
.toBe(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY))
})
it('skips a CSS-wide keyword instead of building an invalid declaration', () => {
// Skins do set tokens to these; appending to one yields an invalid
// font-family that the CSSOM discards, losing every font at once.
for (const keyword of ['inherit', 'initial', 'unset', 'revert', 'REVERT-LAYER']) {
const { fontFamily } = resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, keyword)
expect(fontFamily).toBe(withIconFontFallbacks(DEFAULT_TERMINAL_FONT_FAMILY))
expect(fontFamily.toLowerCase()).not.toContain(keyword.toLowerCase())
}
// Same guard on the user pref, which then defers to the theme font.
expect(resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: 'inherit' }, 'var-theme-font').fontFamily)
.toBe(withIconFontFallbacks('var-theme-font'))
})
it('prefers the custom family as the base and clamps the size', () => {
const { fontFamily, fontSize } = resolveTerminalFont(
{ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: '"JetBrains Mono", monospace', terminalFontSize: 40 },
'var-theme-font',
)
expect(fontFamily).toBe(withIconFontFallbacks('"JetBrains Mono", monospace'))
expect(fontFamily.startsWith('"JetBrains Mono"')).toBe(true)
expect(fontFamily).not.toContain('var-theme-font')
expect(fontSize).toBe(TERMINAL_FONT_SIZE_MAX)
})
it('applies icon fallbacks no matter which base family wins', () => {
for (const resolved of [
resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, 'var-theme-font'),
resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, undefined),
resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: 'Menlo' }, undefined),
]) {
expect(resolved.fontFamily).toContain('"Symbols Nerd Font Mono"')
}
})
})