* feat(fonts): add CJK font pairing composition module Introduces composeFontFamilyStack() which builds the xterm fontFamily CSS string at runtime from: - the user's primary Latin font - an explicit CJK font (TerminalSettings.fallbackFont) if set - otherwise a per-Latin-font recommended CJK pairing - a hardcoded system CJK fallback stack - a Nerd Font icon fallback stack - the universal monospace generic 14 unit tests cover composition order, deduplication, OS defaults, quoting, and recommendation override behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(fonts): expose raw Latin families and add CJK-coverage entries - TERMINAL_FONTS[].family no longer bakes in the CJK fallback stack; composition is deferred to runtime via composeFontFamilyStack(). - Drops withCjkFallback helper from this module and its caller in lib/localFonts.ts. - Adds 6 CJK-coverage primary fonts to the dropdown: Sarasa Mono SC/TC, Maple Mono CN, LXGW WenKai Mono, Microsoft YaHei UI, PingFang SC. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(terminal): compose font-family stack with user-configurable CJK fallback resolvedFontFamily now passes through composeFontFamilyStack(), which prepends the user's TerminalSettings.fallbackFont (if set) ahead of the per-Latin-font recommended CJK pairing and the system fallback stack. The platform argument is derived from navigator.platform inside the useMemo, so the same Latin font may pair with PingFang SC on macOS and Microsoft YaHei UI on Windows out of the box. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(settings): add CJK font picker to terminal settings Adds a new "CJK font" select row right under the main font selector in the Terminal settings tab. Bound to TerminalSettings.fallbackFont (an already-existing-but-unused field), so this needs no schema or sync payload change. Default value "Auto" leaves fallbackFont empty, which lets the new per-Latin-font pairing in cjkFonts.ts pick a CJK font automatically. Selecting any explicit option (Sarasa Mono SC, PingFang SC, Microsoft YaHei UI, etc.) takes precedence over the per-font pairing. Includes en + zh-CN i18n strings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(sync): cover fallbackFont round-trip + legacy payload tolerance Four new test cases verify cloud-sync compatibility for the new CJK font setting: - buildSyncPayload includes fallbackFont when set - buildSyncPayload omits fallbackFont when unset - applySyncPayload writes incoming fallbackFont to TERM_SETTINGS - applySyncPayload from a legacy client (no fallbackFont) does NOT wipe the local value — critical for old-to-new upgrades Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(fonts): add font availability detection (canvas + document.fonts API) Three-layer detection used by isFontInstalled(family): 1. Known @fontsource-bundled families (e.g. JetBrains Mono) always count as installed. 2. document.fonts.check() — picks up @font-face and system-loaded fonts. 3. Canvas width measurement against serif / sans-serif / monospace fallbacks; only counts if the target font produces a width that differs from ALL three generics for a probe string. detectInstalledWithContext is a pure function taking an injected measurement context, which keeps the canvas / DOM behind a seam and lets the logic be unit-tested without a browser. 11 tests cover quoted-family parsing, the three-generic-fallback rule, bundled short-circuit, and document.fonts.check fast-path. Results are cached per process; clearFontAvailabilityCache() invalidates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(fonts): filter dropdowns to fonts actually installed on this machine Layer 3 of #931 added Sarasa Mono SC / Maple Mono CN / Microsoft YaHei UI / PingFang SC etc. to the terminal font dropdown, but users who don't have these installed would still see them and pick them — resulting in "I changed the font and nothing happened" confusion. This commit filters both dropdowns through isFontInstalled(): - TerminalFontSelect: drops any built-in or system-discovered font that detection can't render. If filtering would leave fewer than 4 fonts (detection misfire safety net), shows the full list. - TerminalCjkFontSelect: keeps the "Auto" sentinel always, drops concrete CJK choices that aren't present on this machine. Both selects always keep the currently-selected value visible — even when the underlying font is missing — so users can read and clear their setting without surprise. Also expands `npm test` globs to pick up infrastructure/config/*.test.ts and lib/*.test.ts, which previously matched no patterns and meant the new cjkFonts and fontAvailability suites were silently excluded from CI runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): never recommend proportional CJK fonts for terminal use The previous PingFang SC / Microsoft YaHei UI / Hiragino Sans GB choices were proportional sans-serif fonts whose CJK glyphs aren't designed to fit a terminal's 2x cell grid — the rendered Chinese ended up visibly wider than its allocated cells, breaking grid alignment (reported on macOS with PingFang SC selected as the CJK font). Changes: - TerminalCjkFontSelect: drops PingFang SC / Microsoft YaHei UI / Hiragino Sans GB from the dropdown. Legacy explicit selections still surface as a synthetic "not recommended" option so users can see and re-pick. - CJK_SYSTEM_FALLBACK_FONTS: monospace-only list. Sarasa Mono SC/TC, Maple Mono CN, LXGW WenKai Mono, Noto Sans Mono CJK SC, Source Han Mono SC, NSimSun, SimSun. Proportional fonts removed. - PER_FONT_CJK_PAIRING: every entry now points at a true monospace CJK font. Cascadia / Consolas / Menlo etc. all recommend Sarasa Mono SC, which the next commit bundles via @font-face. - getDefaultCjkFallback: Windows = SimSun (always installed, monospace); macOS = Sarasa Mono SC (will be bundled); Linux = Noto Sans Mono CJK SC. A regression test enforces that no per-OS default is a known proportional font. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(fonts): bundle Sarasa Mono SC as the universal CJK monospace Previous commit removed proportional CJK fonts (PingFang SC, etc.) from the picker and switched per-OS defaults to true monospace, but macOS ships NO system-installed monospace CJK font — leaving macOS users with a broken default unless they manually install Sarasa or similar. This commit closes that gap by bundling Sarasa Mono SC as an @font-face webfont, so the recommended pairings and macOS default "just work" out of the box. Details: - public/fonts/SarasaMonoSC-Regular.woff2 (~4.8 MB): subsetted from be5invis/Sarasa-Gothic v1.0.37 SarasaMonoSC-Regular.ttf (24 MB). Covers ASCII, Latin-1, common punctuation/symbols, CJK Unified Ideographs main block, Hiragana/Katakana, halfwidth/fullwidth, box-drawing — the everyday-Chinese coverage that matters for a terminal. Rare CJK Ext-A/B/historical chars fall through to the system fallback stack. - public/fonts/SarasaMono-LICENSE.txt: OFL-1.1 verbatim, required by the license. - index.css: @font-face declaration with font-display: swap so the user doesn't see a flash of nothing while the woff2 loads. - KNOWN_BUNDLED_FAMILIES: "Sarasa Mono SC" added so the dropdown availability filter doesn't hide it. Installer impact: ~+4.8 MB (vs current ~100-200 MB Electron baseline). The font replaces what would otherwise have been "Chinese chars look broken in the terminal" for every macOS user without a manually installed CJK monospace font. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): use Local Font Access API as the authoritative install check document.fonts.check() turned out to be unreliable as an installed-font signal in Chromium — it returns true for any syntactically-valid family name regardless of whether the font is actually installed, as a deliberate fingerprinting-mitigation. The previous detector took it as a positive signal and ended up keeping uninstalled fonts in the dropdown (reported by a macOS user seeing dozens of fonts they don't have). This commit pivots the detection chain: - lib/localFonts.ts: getAllSystemFontFamilies() exposes the unfiltered set of installed family names from queryLocalFonts(), reusing the same underlying call as getMonospaceFonts() via a shared cache. - lib/fontAvailability.ts: drops the document.fonts.check fast-path. Adds setSystemFamilies() / hasAuthoritativeData(). When the set has been populated, isFontInstalled answers from membership lookup directly — no canvas guessing. Canvas remains as a fallback for environments where the Local Font Access API is unavailable or permission is denied. - application/state/fontStore.ts: during initialize(), runs the monospace-only query and the full-system-families query together, then pipes the result into fontAvailability. - TerminalFontSelect: with authoritative data, drops the "if filtered list is suspiciously small, show all" safety net. Empty would now really mean empty (highly unlikely since Sarasa Mono SC is bundled). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): drop PingFang SC / Microsoft YaHei UI from primary dropdown Step 1 of this PR removed proportional CJK fonts from the CJK fallback picker but left them in BASE_TERMINAL_FONTS, so PingFang SC and Microsoft YaHei UI were still selectable as the *primary* terminal font. Picking PingFang SC as primary produced visibly bloated Latin character spacing (xterm.js samples cell width from the primary font; the wide proportional 'M' inflates every cell), reported by a macOS user in the same thread that opened #931. Both entries are removed from BASE_TERMINAL_FONTS. A new infrastructure/config/fonts.test.ts asserts that no known proportional CJK font name (including PingFang TC/HK, Microsoft YaHei variants, Hiragino Sans GB, Heiti SC/TC) is ever shipped in TERMINAL_FONTS as a primary choice. Migration for users already saved to one of the removed ids: useSettingsState rewrites STORAGE_KEY_TERM_FONT_FAMILY to the default (Menlo) on read when it sees a deprecated id, so the bad value also stops getting carried into cloud-sync uploads. Per-host fontFamily overrides are NOT migrated automatically — they still gracefully fall through to the dropdown's first entry via the existing getFontById fallback; users can re-pick from the host settings UI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): drop Comic Sans MS — it's a proportional handwriting font Same symptom as the PingFang SC / Microsoft YaHei UI removal: Comic Sans MS was historically in the primary font dropdown labeled "Casual, non-traditional terminal font", but Comic Sans is a handwriting-style proportional sans-serif. Picking it as the terminal primary inflates cell width and spaces every Latin character far apart (reported in the same #931 thread). - BASE_TERMINAL_FONTS: comic-sans-ms entry removed. - DEPRECATED_PRIMARY_FONT_IDS: gains comic-sans-ms so existing selections silently migrate to Menlo on read. - fonts.test.ts: the proportional-font ban list now also covers Latin proportional fonts (Comic Sans MS, Arial, Helvetica, Times New Roman, Georgia, Verdana, Trebuchet MS, Tahoma) so the test catches any future mislabeled body-text font from being added to the terminal dropdown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): keep monospace ahead of CJK fallbacks in composed stack Addresses codex P1 review comment on PR #940 (https://github.com/binaricat/Netcatty/pull/940#discussion_r3216017737). The previous behavior of withCjkFallback() had monospace immediately after the primary family, before any CJK fallback. composeFontFamilyStack had moved monospace to the very end, which means: when the primary font isn't installed on the user's machine (common for Layer 3 CJK choices that aren't bundled and not present on a given OS, or for any built-in id like cascadia-code on a Linux system without it), CSS per-glyph fallback resolves Latin glyphs from a CJK font's full-width Latin variants before ever reaching monospace generic. That breaks xterm.js's fixed cell-grid alignment. The composed stack now reads: <primary>, monospace, <userFallback>, <recommended-cjk>, <system-cjk-stack>, <nerd-font-stack> Per-glyph CSS fallback behavior: - Latin → primary if installed → monospace generic. Cell width stays consistent. - CJK → primary (no) → monospace (no Chinese glyphs) → walks into CJK fallbacks. - Nerd PUA → falls past all of the above into the Nerd Font stack. Updates the position-invariant tests and adds a regression test that explicitly asserts monospace appears before every CJK family in the output stack. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): dedupe Local Font Access API calls under concurrent init Addresses codex P2 review on PR #940: https://github.com/binaricat/Netcatty/pull/940#discussion_r3216246xxx fontStore.initialize() runs getMonospaceFonts() and getAllSystemFontFamilies() in Promise.all; both internally called queryAllSystemFontsOnce(), whose cache check (`if (cache) return`) was only useful once the result had been written. Concurrent callers both passed the empty-cache check and fired their own queryLocalFonts() request — two real Local Font Access API invocations on cold start, with the risk of one succeeding while the other was denied (leaving the authoritative set unset). Fix: cache the *in-flight promise itself*, so subsequent callers await the same single invocation. The first await populates the family-set cache as a side effect, and the resolved promise keeps returning the same value to every subsequent caller. Adds lib/localFonts.test.ts with three regression tests: - concurrent getMonospaceFonts + getAllSystemFontFamilies = 1 API call - sequential repeats also reuse the resolved promise - missing API returns null authoritative set (canvas fallback signal) Exports __resetLocalFontsCacheForTesting() so each test gets a fresh module-level state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): retry LFA on transient failure + notify on availability changes Two follow-up fixes from codex P2 review on PR #940: 1) queryAllSystemFontsOnce() previously kept its in-flight promise even when queryLocalFonts threw. Subsequent callers reused the cached empty result for the rest of the session, so any transient failure at boot (permission state not ready, AbortError, etc.) permanently blinded the rest of the app to installed fonts. Catch now clears queryPromise so the next caller retries. Regression test added. 2) TerminalCjkFontSelect.visibleOptions and TerminalFontSelect .visibleFonts were memoized on [value] / [fonts, value] only, but the filter calls isFontInstalled() which reads module-level systemFamilies — a value that arrives asynchronously after the initial render. The memos never recomputed when authoritative availability data landed, so the dropdowns could continue showing stale "filtered" results until the user changed selection. fontAvailability now exposes subscribeFontAvailability() and getFontAvailabilityVersion() (monotonic counter bumped on setSystemFamilies / clearFontAvailabilityCache). Both selects subscribe via useSyncExternalStore and include the version in their memo deps; tests cover subscriber notification and version monotonicity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): migrate host/group deprecated font ids + localize CJK labels Two follow-up fixes from codex review on PR #940: P2 — Host/group level font migration ==================================== The earlier deprecated-id migration only rewrote STORAGE_KEY_TERM_FONT_FAMILY, so hosts and group configs that had explicitly opted into a now-removed font id (e.g. pingfang-sc, microsoft-yahei, comic-sans-ms) kept `fontFamily` set with `fontFamilyOverride=true`. After the dropdown entries were dropped in 9f2bd282/c9b622d8, those records silently fell through to the first font in the registry (Menlo) while the override flag still read "true" — users saw a host claiming a custom font but rendering the global default with no way to tell what happened. Fix: - infrastructure/config/fonts.ts gains migrateDeprecatedFontOverride(), a structurally-shared helper that drops fontFamily and clears fontFamilyOverride when the id is deprecated. - sanitizeHost now runs it on every host load. - domain/groupConfig.ts grows sanitizeGroupConfig(); useVaultState applies it both on initial load and on cross-tab storage events. - Existing decrypt → sanitize → encrypt round-trip in useVaultState means the migrated values are persisted back to localStorage and propagate through cloud sync naturally. Tests: two each in domain/host.test.ts and domain/groupConfig.test.ts covering deprecated-id reset and untouched-valid-id preservation. P3 — Localize CJK font option labels ==================================== TerminalCjkFontSelect previously hardcoded Chinese option labels ("Auto · 按主字体智能搭配", "Sarasa Mono SC (更纱黑体 简)", etc.) and the synthetic "not recommended" warning. Non-Chinese locales saw a mixed-language UI despite the rest of the setting going through i18n. OPTIONS now references i18n keys; the component looks them up via useI18n(). Both en and zh-CN locales gain matching keys, including `...option.legacy` with `{font}` interpolation for the synthetic "not recommended" item that surfaces saved-but-removed values. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): also sanitize group configs on the write/import path Addresses codex P2 review on PR #940: https://github.com/binaricat/Netcatty/pull/940#discussion_r3216314xxx The previous commit (09c87820) added sanitizeGroupConfig() but only plumbed it into the decrypt paths (initial load + storage event). updateGroupConfigs() — which is also the write path used by applySyncPayload / importVaultData when ingesting a legacy payload — still set state from raw input. A sync from an older client carrying { fontFamily: "pingfang-sc", fontFamilyOverride: true } would land in memory unsanitized AND be re-persisted with the bad override active until the next reload re-ran the decrypt path. Fix mirrors updateHosts → sanitizeHost: map every incoming entry through sanitizeGroupConfig before both setGroupConfigs and the encrypt-and-persist step. Same call site now feeds the cleaned data to localStorage, so legacy values are scrubbed on first import. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): migrate deprecated terminal font ids on every ingest path Addresses codex P2 review on PR #940: https://github.com/binaricat/Netcatty/pull/940#discussion_r3216517xxx The previous migration only ran in the initial useState() initializer for terminalFontFamilyId, so deprecated ids (pingfang-sc / microsoft-yahei / comic-sans-ms) could still re-enter state via: - rehydrateAllFromStorage() at line ~527 — runs on remote-import completion and re-reads STORAGE_KEY_TERM_FONT_FAMILY raw. - The notifySettingsChanged IPC handler at line ~663 — fires when a cloud sync or programmatic localStorage write announces a change. - The cross-window storage event handler at line ~873. Any of these paths could pull a deprecated id back into state after the initial migration ran, leaving the font selector with no matching option and silently rendering the global default while continuing to propagate the stale value through subsequent sync uploads. Centralizes the migration in migrateIncomingTerminalFontId(raw): - returns null when raw is empty - if raw is deprecated, writes DEFAULT_FONT_FAMILY back to localStorage AND returns it - otherwise returns raw unchanged All four ingest sites (initial init, rehydrate, IPC, storage event) now route through this helper. The rewrite-on-deprecated semantics also guarantee that the moment any path sees a bad value, the next sync upload carries the cleaned default — not the deprecated id. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): use bundled Latin-only fallback instead of monospace generic Resolves the tension between codex's two P1 reviews on PR #940: Round 1 (da1fe4cd): "monospace must come BEFORE CJK fallbacks" — otherwise Latin glyphs fall into a CJK font's full-width Latin when the primary font is missing. Round 2 (this commit): "monospace must come AFTER CJK fallbacks" — otherwise on macOS Chrome, the generic `monospace` pulls in PingFang via Chromium's CJK system fallback and silently masks the user's CJK picker. Both are right; using a single `monospace` token can't satisfy both roles because `monospace` is a generic family whose CJK-glyph coverage is platform-dependent. Fix mirrors Tabby's approach (their "monospace-fallback" SourceCodePro sitting before any CJK in the chain): insert a known Latin-only bundled font between the primary and CJK fallbacks. JetBrains Mono is already shipped via @fontsource/jetbrains-mono and carries no CJK glyphs, so it catches Latin without intercepting Chinese. New stack order: <primary>, "JetBrains Mono", <userFallback>, <recommended-cjk>, <system-cjk-stack>, <nerd-font-stack>, monospace Per-glyph CSS fallback now behaves as intended on every platform: - Latin: primary (if installed) → JetBrains Mono. Cells stay aligned. - CJK: primary (no) → JetBrains Mono (no CJK glyphs) → user CJK pick. - Nerd PUA: all of the above → Nerd Font stack. Replaces the two prior positional-invariant tests with one for each codex review concern: JetBrains Mono precedes every CJK family (Latin alignment), and user CJK precedes generic monospace (CJK picker effectiveness). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): use OR-of-fallbacks for canvas font detection Addresses codex P2 review on PR #940: https://github.com/binaricat/Netcatty/pull/940#discussion_r3216556xxx detectInstalledWithContext required the target font to produce a different rendered width from *all three* generic fallbacks (serif, sans-serif, monospace) to be counted as installed. That's too strict: on macOS the `monospace` generic resolves to Menlo itself, so measure(`"Menlo", monospace`) === measure(`monospace`), and the detector reported Menlo as missing even when it was clearly installed. The same false-negative trap exists for any font that happens to share metrics with one of the three generics on a given platform. Switches to OR-of-fallbacks: a font counts as installed if its rendered width differs from at least one generic baseline. A truly uninstalled font still falls through to each generic in turn and matches all three baselines, so this doesn't introduce false positives. Regression tests added for both directions: - Menlo with metrics identical to `monospace` generic → installed. - "Definitely Not Installed" font → still reported missing. The path only fires when the Local Font Access API is unavailable or denied — when LFA succeeds, `setSystemFamilies` short-circuits ahead of canvas — so this primarily improves the degraded-permission scenario. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): quote-aware tokenizer for font-family lists Addresses codex P2 review on PR #940: https://github.com/binaricat/Netcatty/pull/940#discussion_r3216559xxx composeFontFamilyStack and extractPrimaryFamily both tokenized their input with a raw String.split(',') — which corrupts any CSS family list whose quoted family name contains a comma (CSS allows that, e.g. `"Foo, Inc. Mono"` is a single family). A naive split would shred that into `"Foo` / `Inc. Mono"` and emit a malformed font-family back out. No current TERMINAL_FONTS entry hits this case, but lib/localFonts.ts builds family strings from arbitrary system fonts via the Local Font Access API — a user with a comma-bearing family name would have silently broken filtering until now. Adds splitFontFamilyList(css) in cjkFonts.ts: an exported quote-aware tokenizer that splits on commas only when outside quoted segments (handles both " and '). composeFontFamilyStack uses it instead of raw split; extractPrimaryFamily in lib/fontAvailability.ts imports it for symmetry so the two call sites can't drift. Tests cover the tokenizer directly (simple list, quoted-with-comma, single quotes, double commas) and end-to-end (a quoted primary with an internal comma survives composition intact). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(fonts): translate Layer 3 CJK font descriptions to English The 4 CJK-coverage entries added in earlier commits (Sarasa Mono SC, Sarasa Mono TC, Maple Mono CN, LXGW WenKai Mono) had hardcoded Chinese description strings, while every other TERMINAL_FONTS entry uses English ('Adobe's professional programming font', 'Iosevka variant mimicking Berkeley Mono style', etc.). The dropdown rendered a mixed-language list — flagged by the maintainer. Converted the 4 descriptions to English in the same style as the existing entries. No i18n scaffolding added; the existing convention is "English-only `description` field, not routed through t()", and the rest of the registry stays consistent with that. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
204 lines
7.6 KiB
TypeScript
204 lines
7.6 KiB
TypeScript
import { describe, it, beforeEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
extractPrimaryFamily,
|
|
detectInstalledWithContext,
|
|
isFontInstalled,
|
|
setSystemFamilies,
|
|
hasAuthoritativeData,
|
|
clearFontAvailabilityCache,
|
|
subscribeFontAvailability,
|
|
getFontAvailabilityVersion,
|
|
} from './fontAvailability';
|
|
|
|
describe('extractPrimaryFamily', () => {
|
|
it('strips quotes from a quoted name', () => {
|
|
assert.equal(extractPrimaryFamily('"Fira Code", monospace'), 'Fira Code');
|
|
});
|
|
it('returns unquoted single-word names as-is', () => {
|
|
assert.equal(extractPrimaryFamily('Menlo, monospace'), 'Menlo');
|
|
});
|
|
it('returns the first family in a list', () => {
|
|
assert.equal(
|
|
extractPrimaryFamily('"Source Code Pro", "Fira Code", monospace'),
|
|
'Source Code Pro',
|
|
);
|
|
});
|
|
it('handles a single name without comma', () => {
|
|
assert.equal(extractPrimaryFamily('Iosevka'), 'Iosevka');
|
|
});
|
|
});
|
|
|
|
function makeContextWithInstalledFamilies(installed: Set<string>) {
|
|
// Mock canvas measurement: each generic fallback has a stable width;
|
|
// a "real" installed font produces a different width per fallback.
|
|
// Collision-resistant: position-weighted polynomial hash.
|
|
const widthFor = (family: string): number => {
|
|
let h = 0;
|
|
for (let i = 0; i < family.length; i++) {
|
|
h = (h * 31 + family.charCodeAt(i)) >>> 0;
|
|
}
|
|
return 100 + (h % 9973);
|
|
};
|
|
return {
|
|
measureText: (font: string, _text: string) => {
|
|
const match = font.match(/^\d+px\s+(.+)$/);
|
|
if (!match) return 0;
|
|
const familyList = match[1];
|
|
const families = familyList
|
|
.split(',')
|
|
.map((f) => f.trim().replace(/^["']|["']$/g, ''));
|
|
for (const f of families) {
|
|
if (installed.has(f) || ['serif', 'sans-serif', 'monospace'].includes(f)) {
|
|
return widthFor(f);
|
|
}
|
|
}
|
|
return 0;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('detectInstalledWithContext (canvas fallback)', () => {
|
|
it('detects an installed font (width differs from all 3 generic fallbacks)', () => {
|
|
const ctx = makeContextWithInstalledFamilies(new Set(['Fira Code']));
|
|
assert.equal(detectInstalledWithContext('Fira Code', ctx), true);
|
|
});
|
|
|
|
it('rejects a non-installed font (falls through to fallback)', () => {
|
|
const ctx = makeContextWithInstalledFamilies(new Set(['Fira Code']));
|
|
assert.equal(detectInstalledWithContext('Definitely Not A Font', ctx), false);
|
|
});
|
|
|
|
it('treats KNOWN_BUNDLED_FAMILIES as installed regardless of canvas evidence', () => {
|
|
const ctx = makeContextWithInstalledFamilies(new Set());
|
|
assert.equal(detectInstalledWithContext('JetBrains Mono', ctx), true);
|
|
assert.equal(detectInstalledWithContext('Sarasa Mono SC', ctx), true);
|
|
});
|
|
|
|
it('treats a font as installed when it matches one generic but differs from the others', () => {
|
|
// Regression guard for codex P2 review on PR #940: on macOS the
|
|
// `monospace` generic resolves to Menlo, so measure(`"Menlo", monospace`)
|
|
// equals measure(`monospace`). The detector must NOT report Menlo
|
|
// as uninstalled just because of that single collision — it should
|
|
// recognize installation via the other two generic baselines.
|
|
const ctx = {
|
|
measureText: (font: string): number => {
|
|
// "Menlo", X → Menlo's metrics (always 100, regardless of fallback)
|
|
if (font.includes('"Menlo"')) return 100;
|
|
// Generic baselines
|
|
if (font === '72px serif') return 50;
|
|
if (font === '72px sans-serif') return 80;
|
|
if (font === '72px monospace') return 100; // identical to Menlo
|
|
// Unknown family followed by a generic → falls to that generic
|
|
const tail = font.split(',').pop()?.trim() ?? '';
|
|
if (tail === 'serif') return 50;
|
|
if (tail === 'sans-serif') return 80;
|
|
if (tail === 'monospace') return 100;
|
|
return 0;
|
|
},
|
|
};
|
|
assert.equal(detectInstalledWithContext('Menlo', ctx), true);
|
|
});
|
|
|
|
it('still reports a clearly-uninstalled font as missing even with the looser rule', () => {
|
|
// "Some" semantics must not introduce false positives for fonts
|
|
// that genuinely aren't installed — those fall through to each
|
|
// generic and match all three baselines.
|
|
const ctx = makeContextWithInstalledFamilies(new Set(['Menlo']));
|
|
assert.equal(detectInstalledWithContext('Definitely Not Installed', ctx), false);
|
|
});
|
|
});
|
|
|
|
describe('isFontInstalled with authoritative system data', () => {
|
|
beforeEach(() => {
|
|
clearFontAvailabilityCache();
|
|
});
|
|
|
|
it('returns true for bundled families even without authoritative data', () => {
|
|
assert.equal(hasAuthoritativeData(), false);
|
|
assert.equal(isFontInstalled('JetBrains Mono'), true);
|
|
assert.equal(isFontInstalled('Sarasa Mono SC'), true);
|
|
});
|
|
|
|
it('answers from authoritative set once setSystemFamilies has run', () => {
|
|
setSystemFamilies(new Set(['menlo', 'fira code']));
|
|
assert.equal(hasAuthoritativeData(), true);
|
|
assert.equal(isFontInstalled('Menlo'), true);
|
|
assert.equal(isFontInstalled('Fira Code'), true);
|
|
assert.equal(isFontInstalled('Sarasa Mono SC'), true, 'bundled wins over set');
|
|
assert.equal(isFontInstalled('PingFang SC'), false, 'not in authoritative set');
|
|
assert.equal(isFontInstalled('Programmer Fonts'), false, 'fictitious name');
|
|
});
|
|
|
|
it('lookup is case-insensitive (set stores lowercase)', () => {
|
|
setSystemFamilies(new Set(['microsoft yahei ui']));
|
|
assert.equal(isFontInstalled('Microsoft YaHei UI'), true);
|
|
assert.equal(isFontInstalled('MICROSOFT YAHEI UI'), true);
|
|
});
|
|
|
|
it('falls back to safe-default (true) without DOM and without authoritative data', () => {
|
|
assert.equal(hasAuthoritativeData(), false);
|
|
assert.equal(isFontInstalled('Some Unknown Font'), true);
|
|
});
|
|
|
|
it('a null authoritative set means we re-enter fallback mode', () => {
|
|
setSystemFamilies(new Set(['menlo']));
|
|
assert.equal(hasAuthoritativeData(), true);
|
|
setSystemFamilies(null);
|
|
assert.equal(hasAuthoritativeData(), false);
|
|
});
|
|
});
|
|
|
|
describe('font availability subscription', () => {
|
|
beforeEach(() => {
|
|
clearFontAvailabilityCache();
|
|
});
|
|
|
|
it('notifies subscribers when setSystemFamilies is called', () => {
|
|
// Regression guard for codex P2 review on PR #940:
|
|
// TerminalCjkFontSelect memoizes visibleOptions on [value] but the
|
|
// filter calls isFontInstalled which depends on systemFamilies.
|
|
// Subscribers wired via useSyncExternalStore must fire so memos
|
|
// recompute when authoritative data arrives.
|
|
let calls = 0;
|
|
const unsubscribe = subscribeFontAvailability(() => {
|
|
calls += 1;
|
|
});
|
|
|
|
setSystemFamilies(new Set(['menlo']));
|
|
assert.equal(calls, 1);
|
|
|
|
setSystemFamilies(new Set(['menlo', 'fira code']));
|
|
assert.equal(calls, 2);
|
|
|
|
setSystemFamilies(null);
|
|
assert.equal(calls, 3);
|
|
|
|
unsubscribe();
|
|
setSystemFamilies(new Set(['menlo']));
|
|
assert.equal(calls, 3, 'unsubscribe stops notifications');
|
|
});
|
|
|
|
it('version monotonically increases on each setSystemFamilies call', () => {
|
|
const v0 = getFontAvailabilityVersion();
|
|
setSystemFamilies(new Set(['menlo']));
|
|
const v1 = getFontAvailabilityVersion();
|
|
setSystemFamilies(new Set(['menlo', 'fira code']));
|
|
const v2 = getFontAvailabilityVersion();
|
|
|
|
assert.ok(v1 > v0, 'first call bumps version');
|
|
assert.ok(v2 > v1, 'second call bumps version');
|
|
});
|
|
|
|
it('clearFontAvailabilityCache also notifies subscribers', () => {
|
|
let calls = 0;
|
|
subscribeFontAvailability(() => {
|
|
calls += 1;
|
|
});
|
|
setSystemFamilies(new Set(['menlo']));
|
|
const after = calls;
|
|
clearFontAvailabilityCache();
|
|
assert.ok(calls > after, 'clear notifies too');
|
|
});
|
|
});
|