316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
|
|
import { describe, it } from 'node:test';
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import {
|
||
|
|
composeFontFamilyStack,
|
||
|
|
getDefaultCjkFallback,
|
||
|
|
getRecommendedCjkFor,
|
||
|
|
splitFontFamilyList,
|
||
|
|
CJK_SYSTEM_FALLBACK_STACK,
|
||
|
|
} from './cjkFonts';
|
||
|
|
|
||
|
|
describe('composeFontFamilyStack', () => {
|
||
|
|
it('puts the primary font first', () => {
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: 'Menlo, monospace',
|
||
|
|
userFallback: '',
|
||
|
|
latinFontId: 'menlo',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
assert.match(stack, /^Menlo,\s*/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('inserts user fallback right after primary when provided', () => {
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: '"Fira Code", monospace',
|
||
|
|
userFallback: 'Sarasa Mono SC',
|
||
|
|
latinFontId: 'fira-code',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
const firaIdx = stack.indexOf('Fira Code');
|
||
|
|
const userIdx = stack.indexOf('Sarasa Mono SC');
|
||
|
|
assert.ok(firaIdx >= 0 && userIdx > firaIdx, 'user fallback after primary');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('uses per-Latin-font recommended CJK when user fallback is empty', () => {
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: '"Cascadia Code", monospace',
|
||
|
|
userFallback: '',
|
||
|
|
latinFontId: 'cascadia-code',
|
||
|
|
platform: 'win32',
|
||
|
|
});
|
||
|
|
// Cascadia Code now recommends Sarasa Mono SC (true monospace).
|
||
|
|
assert.match(stack, /Sarasa Mono SC/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('falls back to OS default when Latin font has no recommendation', () => {
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: '"Unknown Font", monospace',
|
||
|
|
userFallback: '',
|
||
|
|
latinFontId: 'unknown',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
// macOS no-recommendation default is now Sarasa Mono SC (bundled).
|
||
|
|
assert.match(stack, /Sarasa Mono SC/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('quotes multi-word user fallback names', () => {
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: 'Menlo, monospace',
|
||
|
|
userFallback: 'Source Han Mono SC',
|
||
|
|
latinFontId: 'menlo',
|
||
|
|
platform: 'linux',
|
||
|
|
});
|
||
|
|
assert.match(stack, /"Source Han Mono SC"/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('keeps an unquoted user fallback containing a comma as one font family', () => {
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: 'Menlo, monospace',
|
||
|
|
userFallback: 'Foo, Inc. Mono',
|
||
|
|
latinFontId: 'menlo',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.ok(stack.includes('"Foo, Inc. Mono"'));
|
||
|
|
assert.ok(splitFontFamilyList(stack).includes('"Foo, Inc. Mono"'));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('escapes quotes inside a manually entered font family', () => {
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: 'Menlo, monospace',
|
||
|
|
userFallback: 'Foo"Bar',
|
||
|
|
latinFontId: 'menlo',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.ok(stack.includes('"Foo\\"Bar"'));
|
||
|
|
assert.ok(splitFontFamilyList(stack).includes('"Foo\\"Bar"'));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('quotes a manually entered font family containing an apostrophe', () => {
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: 'Menlo, monospace',
|
||
|
|
userFallback: "Rock'nRoll",
|
||
|
|
latinFontId: 'menlo',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.ok(stack.includes('"Rock\'nRoll"'));
|
||
|
|
assert.ok(splitFontFamilyList(stack).includes('"Rock\'nRoll"'));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('quotes a manually entered font family that starts with a digit', () => {
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: 'Menlo, monospace',
|
||
|
|
userFallback: '3270font',
|
||
|
|
latinFontId: 'menlo',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.ok(stack.includes('"3270font"'));
|
||
|
|
assert.ok(splitFontFamilyList(stack).includes('"3270font"'));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('keeps CSS generic font families unquoted', () => {
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: 'Menlo, monospace',
|
||
|
|
userFallback: 'ui-monospace',
|
||
|
|
latinFontId: 'menlo',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.ok(splitFontFamilyList(stack).includes('ui-monospace'));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does not duplicate identical fallback entries', () => {
|
||
|
|
// User explicitly picks the same font the per-font pairing would,
|
||
|
|
// and that font also lives in the system stack — should appear once.
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: '"Cascadia Code", monospace',
|
||
|
|
userFallback: 'Sarasa Mono SC',
|
||
|
|
latinFontId: 'cascadia-code',
|
||
|
|
platform: 'win32',
|
||
|
|
});
|
||
|
|
const matches = stack.match(/Sarasa Mono SC/g) || [];
|
||
|
|
assert.equal(matches.length, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('inserts JetBrains Mono as Latin-only fallback right after the primary family', () => {
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: 'Menlo',
|
||
|
|
userFallback: '',
|
||
|
|
latinFontId: 'menlo',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
const families = stack.split(',').map((s) => s.trim().replace(/^"|"$/g, ''));
|
||
|
|
assert.equal(families[0], 'Menlo');
|
||
|
|
assert.equal(families[1], 'JetBrains Mono');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('Latin fallback (JetBrains Mono) precedes every CJK family', () => {
|
||
|
|
// Regression guard for codex P1 review on PR #940 (first round):
|
||
|
|
// when the primary font isn't installed, Latin glyphs must fall to
|
||
|
|
// a Latin-only monospace face — NOT a CJK font's full-width Latin
|
||
|
|
// variant — to keep xterm's fixed cell grid aligned. JetBrains Mono
|
||
|
|
// is bundled via @fontsource and contains no CJK glyphs, so it
|
||
|
|
// catches Latin while letting CJK glyphs flow past.
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: '"Fira Code", monospace',
|
||
|
|
userFallback: 'LXGW WenKai Mono',
|
||
|
|
latinFontId: 'fira-code',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
const jbIdx = stack.indexOf('JetBrains Mono');
|
||
|
|
const sarasaIdx = stack.indexOf('Sarasa Mono SC');
|
||
|
|
const userFallbackIdx = stack.indexOf('LXGW WenKai Mono');
|
||
|
|
const simSunIdx = stack.indexOf('SimSun');
|
||
|
|
assert.ok(jbIdx > 0, 'JetBrains Mono must appear in the stack');
|
||
|
|
assert.ok(jbIdx < userFallbackIdx, 'JetBrains Mono before user CJK');
|
||
|
|
assert.ok(jbIdx < sarasaIdx, 'JetBrains Mono before Sarasa system fallback');
|
||
|
|
assert.ok(jbIdx < simSunIdx, 'JetBrains Mono before SimSun system fallback');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('preserves a quoted primary family name that contains a comma', () => {
|
||
|
|
// Regression guard for codex P2 review on PR #940: when the primary
|
||
|
|
// family is something like `"Foo, Inc. Mono"`, the composed stack
|
||
|
|
// must keep that token intact rather than splitting on the internal
|
||
|
|
// comma and emitting fragmented pieces.
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: '"Foo, Inc. Mono", monospace',
|
||
|
|
userFallback: '',
|
||
|
|
latinFontId: 'foo-inc-mono',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
assert.ok(
|
||
|
|
stack.includes('"Foo, Inc. Mono"'),
|
||
|
|
'quoted family with comma stays a single token',
|
||
|
|
);
|
||
|
|
assert.ok(
|
||
|
|
!stack.includes('"Foo,') || stack.includes('"Foo, Inc. Mono"'),
|
||
|
|
'must not produce a dangling `"Foo,` fragment',
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('user-chosen CJK fallback precedes generic monospace', () => {
|
||
|
|
// Regression guard for codex P1 review on PR #940 (second round):
|
||
|
|
// generic `monospace` on macOS Chrome resolves Chinese glyphs to
|
||
|
|
// PingFang via Chromium's CJK system fallback. If `monospace`
|
||
|
|
// appeared in the chain BEFORE the user's CJK pick, CSS per-glyph
|
||
|
|
// fallback would stop at monospace for CJK characters and never
|
||
|
|
// consult the user's choice, silently nullifying the CJK picker.
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: '"Fira Code", monospace',
|
||
|
|
userFallback: 'LXGW WenKai Mono',
|
||
|
|
latinFontId: 'fira-code',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
const userFallbackIdx = stack.indexOf('LXGW WenKai Mono');
|
||
|
|
// Match `monospace` as a standalone token (after the comma+space).
|
||
|
|
const monospaceIdx = stack.lastIndexOf(', monospace');
|
||
|
|
assert.ok(userFallbackIdx > 0, 'user CJK must appear');
|
||
|
|
assert.ok(monospaceIdx > userFallbackIdx, 'user CJK must come before generic monospace');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('explicit user fallback overrides the per-font recommendation', () => {
|
||
|
|
const stack = composeFontFamilyStack({
|
||
|
|
primaryFamily: '"JetBrains Mono", monospace',
|
||
|
|
userFallback: 'LXGW WenKai Mono',
|
||
|
|
latinFontId: 'jetbrains-mono',
|
||
|
|
platform: 'darwin',
|
||
|
|
});
|
||
|
|
// User chose LXGW WenKai Mono; the JetBrains Mono recommendation
|
||
|
|
// (Sarasa Mono SC) should be suppressed, so Sarasa only shows up
|
||
|
|
// later in the system fallback stack, AFTER the user choice.
|
||
|
|
const userIdx = stack.indexOf('LXGW WenKai Mono');
|
||
|
|
const sarasaIdx = stack.indexOf('Sarasa Mono SC');
|
||
|
|
assert.ok(userIdx >= 0);
|
||
|
|
assert.ok(sarasaIdx > userIdx, 'system Sarasa appears after explicit user choice');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getDefaultCjkFallback', () => {
|
||
|
|
it('returns SimSun on Windows (always installed, monospace)', () => {
|
||
|
|
assert.equal(getDefaultCjkFallback('win32'), 'SimSun');
|
||
|
|
});
|
||
|
|
it('returns Sarasa Mono SC on macOS (bundled by app)', () => {
|
||
|
|
assert.equal(getDefaultCjkFallback('darwin'), 'Sarasa Mono SC');
|
||
|
|
});
|
||
|
|
it('returns Noto Sans Mono CJK SC on Linux', () => {
|
||
|
|
assert.equal(getDefaultCjkFallback('linux'), 'Noto Sans Mono CJK SC');
|
||
|
|
});
|
||
|
|
it('never returns a known proportional font', () => {
|
||
|
|
const proportional = ['PingFang SC', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Hiragino Sans GB'];
|
||
|
|
for (const platform of ['darwin', 'win32', 'linux'] as const) {
|
||
|
|
const v = getDefaultCjkFallback(platform);
|
||
|
|
assert.ok(!proportional.includes(v), `${platform} default ${v} must not be proportional`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getRecommendedCjkFor', () => {
|
||
|
|
it('returns null for unknown fonts', () => {
|
||
|
|
assert.equal(getRecommendedCjkFor('unknown-font-id', 'darwin'), null);
|
||
|
|
});
|
||
|
|
it('returns a non-empty string for known fonts', () => {
|
||
|
|
const v = getRecommendedCjkFor('jetbrains-mono', 'darwin');
|
||
|
|
assert.ok(v && v.length > 0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('splitFontFamilyList', () => {
|
||
|
|
it('splits a simple comma-separated list', () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
splitFontFamilyList('Menlo, monospace'),
|
||
|
|
['Menlo', 'monospace'],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('keeps quoted family names with commas intact', () => {
|
||
|
|
// Regression guard for codex P2 review on PR #940: a font family
|
||
|
|
// name like `"Foo, Inc. Mono"` is a single token in CSS, not two.
|
||
|
|
assert.deepEqual(
|
||
|
|
splitFontFamilyList('"Foo, Inc. Mono", monospace'),
|
||
|
|
['"Foo, Inc. Mono"', 'monospace'],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles a single unquoted name', () => {
|
||
|
|
assert.deepEqual(splitFontFamilyList('Iosevka'), ['Iosevka']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles single quotes too', () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
splitFontFamilyList("'Foo, Inc.', serif"),
|
||
|
|
["'Foo, Inc.'", 'serif'],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('keeps escaped quotes inside a quoted family name', () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
splitFontFamilyList('"Foo\\"Bar", monospace'),
|
||
|
|
['"Foo\\"Bar"', 'monospace'],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('drops empty segments produced by double commas', () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
splitFontFamilyList('Menlo,, monospace'),
|
||
|
|
['Menlo', 'monospace'],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('CJK_SYSTEM_FALLBACK_STACK', () => {
|
||
|
|
it('contains true-monospace CJK fonts only', () => {
|
||
|
|
assert.match(CJK_SYSTEM_FALLBACK_STACK, /Sarasa Mono SC/);
|
||
|
|
assert.match(CJK_SYSTEM_FALLBACK_STACK, /Noto Sans Mono CJK SC/);
|
||
|
|
assert.match(CJK_SYSTEM_FALLBACK_STACK, /SimSun/);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does not include known proportional CJK fonts', () => {
|
||
|
|
assert.doesNotMatch(CJK_SYSTEM_FALLBACK_STACK, /PingFang SC/);
|
||
|
|
assert.doesNotMatch(CJK_SYSTEM_FALLBACK_STACK, /Microsoft YaHei UI/);
|
||
|
|
assert.doesNotMatch(CJK_SYSTEM_FALLBACK_STACK, /Hiragino Sans GB/);
|
||
|
|
});
|
||
|
|
});
|