Files
readest/apps/readest-app/src/__tests__/styles/fonts.test.ts
T
Huang Xin 981579c255 feat(sync): cross-device custom font sync (#4077)
* refactor(sync): kind-agnostic replica primitives

Extract dict-only sync into shared primitives (registry, pull/apply
orchestrator, persist env, schema allowlist) so other kinds can plug
in. Companion changes: per-replica Storage Manager grouping,
useReplicaPull boot-race recovery, manifest=null reconciliation on
every boot pull, copyFile takes explicit srcBase + dstBase, settled-
event helpers, lenient webDownload Content-Length (R2/S3 signed URLs
commonly omit it), and generic "File" transfer toast copy any replica
kind can share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sync): cross-device custom font sync

Plug the font replica adapter into the kind-agnostic primitives:
font store gains replica wiring, custom font import publishes the
replica row + queues a binary upload, and bootstrap registers the
font adapter and download-complete handler.

Includes legacy flat-path migration so pre-existing fonts sync
without re-import, full @font-face activation on auto-download
(load + mount the rule, mirroring manual import), and a fix to
createCustomFont so contentId / bundleDir / byteSize survive the
trip through addFont — otherwise import-time publish silently
no-oped on missing contentId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:28:44 +02:00

474 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/utils/misc', () => ({ isCJKEnv: vi.fn(() => false) }));
vi.mock('@/utils/path', () => ({
getFilename: vi.fn((path: string) => path.split('/').pop() || path),
}));
vi.mock('@/utils/md5', () => ({
md5Fingerprint: vi.fn((name: string) => `md5_${name}`),
}));
import { isCJKEnv } from '@/utils/misc';
import {
getFontName,
getFontId,
getFontFormat,
getMimeType,
getCSSFormatString,
createFontFamily,
createFontCSS,
createCustomFont,
mountAdditionalFonts,
mountCustomFont,
type FontFormat,
type CustomFont,
} from '@/styles/fonts';
describe('getFontName', () => {
it('should strip .ttf extension', () => {
expect(getFontName('/fonts/Roboto.ttf')).toBe('Roboto');
});
it('should strip .otf extension', () => {
expect(getFontName('/fonts/Roboto.otf')).toBe('Roboto');
});
it('should strip .woff extension', () => {
expect(getFontName('/fonts/Roboto.woff')).toBe('Roboto');
});
it('should strip .woff2 extension', () => {
expect(getFontName('/fonts/Roboto.woff2')).toBe('Roboto');
});
it('should be case-insensitive for extensions', () => {
expect(getFontName('/fonts/Roboto.TTF')).toBe('Roboto');
expect(getFontName('/fonts/Roboto.OTF')).toBe('Roboto');
expect(getFontName('/fonts/Roboto.WOFF2')).toBe('Roboto');
});
it('should preserve name with dots that are not font extensions', () => {
expect(getFontName('/fonts/My.Custom.Font.ttf')).toBe('My.Custom.Font');
});
it('should return filename as-is when no font extension matches', () => {
expect(getFontName('/fonts/Roboto.txt')).toBe('Roboto.txt');
});
it('should handle path with no directory separators', () => {
expect(getFontName('Roboto.ttf')).toBe('Roboto');
});
it('should handle deeply nested paths', () => {
expect(getFontName('/a/b/c/d/Font.woff2')).toBe('Font');
});
});
describe('getFontId', () => {
it('should return md5 fingerprint of the name', () => {
expect(getFontId('Roboto')).toBe('md5_Roboto');
});
it('should return different ids for different names', () => {
expect(getFontId('Roboto')).not.toBe(getFontId('Arial'));
});
});
describe('getFontFormat', () => {
it('should return ttf for .ttf files', () => {
expect(getFontFormat('font.ttf')).toBe('ttf');
});
it('should return otf for .otf files', () => {
expect(getFontFormat('font.otf')).toBe('otf');
});
it('should return woff for .woff files', () => {
expect(getFontFormat('font.woff')).toBe('woff');
});
it('should return woff2 for .woff2 files', () => {
expect(getFontFormat('font.woff2')).toBe('woff2');
});
it('should be case-insensitive', () => {
expect(getFontFormat('font.TTF')).toBe('ttf');
expect(getFontFormat('font.OTF')).toBe('otf');
expect(getFontFormat('font.WOFF')).toBe('woff');
expect(getFontFormat('font.WOFF2')).toBe('woff2');
});
it('should default to ttf for unknown extensions', () => {
expect(getFontFormat('font.abc')).toBe('ttf');
});
it('should default to ttf for paths without extension', () => {
expect(getFontFormat('font')).toBe('ttf');
});
it('should handle full paths', () => {
expect(getFontFormat('/home/user/fonts/Roboto.woff2')).toBe('woff2');
});
});
describe('getMimeType', () => {
it('should return font/ttf for ttf', () => {
expect(getMimeType('ttf')).toBe('font/ttf');
});
it('should return font/otf for otf', () => {
expect(getMimeType('otf')).toBe('font/otf');
});
it('should return font/woff for woff', () => {
expect(getMimeType('woff')).toBe('font/woff');
});
it('should return font/woff2 for woff2', () => {
expect(getMimeType('woff2')).toBe('font/woff2');
});
it('should return correct types for all known formats', () => {
const formats: FontFormat[] = ['ttf', 'otf', 'woff', 'woff2'];
for (const format of formats) {
expect(getMimeType(format)).toMatch(/^font\//);
}
});
});
describe('getCSSFormatString', () => {
it('should return truetype for ttf', () => {
expect(getCSSFormatString('ttf')).toBe('truetype');
});
it('should return opentype for otf', () => {
expect(getCSSFormatString('otf')).toBe('opentype');
});
it('should return woff for woff', () => {
expect(getCSSFormatString('woff')).toBe('woff');
});
it('should return woff2 for woff2', () => {
expect(getCSSFormatString('woff2')).toBe('woff2');
});
});
describe('createFontFamily', () => {
it('should trim leading and trailing whitespace', () => {
expect(createFontFamily(' Roboto ')).toBe('Roboto');
});
it('should collapse multiple internal spaces', () => {
expect(createFontFamily('Open Sans')).toBe('Open Sans');
});
it('should collapse tabs and mixed whitespace', () => {
expect(createFontFamily('Open\t\tSans')).toBe('Open Sans');
});
it('should return single-word names unchanged', () => {
expect(createFontFamily('Roboto')).toBe('Roboto');
});
it('should handle empty string', () => {
expect(createFontFamily('')).toBe('');
});
it('should handle whitespace-only string', () => {
expect(createFontFamily(' ')).toBe('');
});
});
describe('createFontCSS', () => {
const baseFont: CustomFont = {
id: 'test-id',
name: 'TestFont',
path: '/fonts/TestFont.ttf',
blobUrl: 'blob:http://localhost/abc123',
};
it('should generate valid @font-face CSS', () => {
const css = createFontCSS(baseFont);
expect(css).toContain('@font-face');
expect(css).toContain('font-family: "TestFont"');
expect(css).toContain('format("truetype")');
expect(css).toContain('blob:http://localhost/abc123');
expect(css).toContain('font-display: swap');
});
it('should use family field over name when present', () => {
const font: CustomFont = { ...baseFont, family: 'Custom Family' };
const css = createFontCSS(font);
expect(css).toContain('font-family: "Custom Family"');
});
it('should include font-style and font-weight for non-variable fonts', () => {
const font: CustomFont = { ...baseFont, style: 'italic', weight: 700 };
const css = createFontCSS(font);
expect(css).toContain('font-style: italic');
expect(css).toContain('font-weight: 700');
});
it('should default to normal style and 400 weight', () => {
const css = createFontCSS(baseFont);
expect(css).toContain('font-style: normal');
expect(css).toContain('font-weight: 400');
});
it('should omit font-style and font-weight for variable fonts', () => {
const font: CustomFont = {
...baseFont,
variable: true,
style: 'italic',
weight: 700,
};
const css = createFontCSS(font);
expect(css).not.toContain('font-style:');
expect(css).not.toContain('font-weight:');
});
it('should throw when blobUrl is missing', () => {
const font: CustomFont = { ...baseFont, blobUrl: undefined };
expect(() => createFontCSS(font)).toThrow('Blob URL not available for font: TestFont');
});
it('should use correct format string for otf files', () => {
const font: CustomFont = { ...baseFont, path: '/fonts/TestFont.otf' };
const css = createFontCSS(font);
expect(css).toContain('format("opentype")');
});
it('should use correct format string for woff files', () => {
const font: CustomFont = { ...baseFont, path: '/fonts/TestFont.woff' };
const css = createFontCSS(font);
expect(css).toContain('format("woff")');
});
it('should use correct format string for woff2 files', () => {
const font: CustomFont = { ...baseFont, path: '/fonts/TestFont.woff2' };
const css = createFontCSS(font);
expect(css).toContain('format("woff2")');
});
it('should normalize family name with extra spaces', () => {
const font: CustomFont = { ...baseFont, family: ' Open Sans ' };
const css = createFontCSS(font);
expect(css).toContain('font-family: "Open Sans"');
});
});
describe('createCustomFont', () => {
it('should create font with default name from path', () => {
const font = createCustomFont('/fonts/Roboto.ttf');
expect(font.name).toBe('Roboto');
expect(font.path).toBe('/fonts/Roboto.ttf');
expect(font.id).toBe('md5_Roboto');
});
it('should use custom name when provided', () => {
const font = createCustomFont('/fonts/Roboto.ttf', { name: 'My Font' });
expect(font.name).toBe('My Font');
expect(font.id).toBe('md5_My Font');
});
it('should include optional fields from options', () => {
const font = createCustomFont('/fonts/Roboto.ttf', {
family: 'Roboto Family',
style: 'italic',
weight: 700,
variable: true,
});
expect(font.family).toBe('Roboto Family');
expect(font.style).toBe('italic');
expect(font.weight).toBe(700);
expect(font.variable).toBe(true);
});
it('should leave optional fields undefined when not provided', () => {
const font = createCustomFont('/fonts/Roboto.ttf');
expect(font.family).toBeUndefined();
expect(font.style).toBeUndefined();
expect(font.weight).toBeUndefined();
expect(font.variable).toBeUndefined();
});
it('should preserve replica sync fields (contentId, bundleDir, byteSize) from options', () => {
// Manual font import passes contentId / bundleDir / byteSize through
// addFont → createCustomFont. If createCustomFont drops them, the
// store's publishFontUpsert short-circuits on `!font.contentId` and
// the replica row never publishes — the binary upload also no-ops
// for the same reason. Lock these fields in.
const font = createCustomFont('/fonts/Roboto.ttf', {
contentId: 'content-hash-abc',
bundleDir: 'bundle-1',
byteSize: 4096,
});
expect(font.contentId).toBe('content-hash-abc');
expect(font.bundleDir).toBe('bundle-1');
expect(font.byteSize).toBe(4096);
});
it('should handle woff2 path', () => {
const font = createCustomFont('/fonts/OpenSans.woff2');
expect(font.name).toBe('OpenSans');
expect(font.path).toBe('/fonts/OpenSans.woff2');
});
});
describe('mountAdditionalFonts', () => {
beforeEach(() => {
// Reset document head between tests
document.head.innerHTML = '';
vi.mocked(isCJKEnv).mockReturnValue(false);
});
it('should mount basic Google Fonts link tags', async () => {
await mountAdditionalFonts(document);
const links = document.head.querySelectorAll('link[rel="stylesheet"]');
expect(links.length).toBeGreaterThanOrEqual(1);
// Verify at least one link points to Google Fonts
const hrefs = Array.from(links).map((l) => l.getAttribute('href') || '');
expect(hrefs.some((h) => h.includes('fonts.googleapis.com'))).toBe(true);
});
it('should set crossOrigin on link tags', async () => {
await mountAdditionalFonts(document);
const links = document.head.querySelectorAll('link');
for (const link of Array.from(links)) {
expect(link.crossOrigin).toBe('anonymous');
}
});
it('should not mount CJK fonts when isCJK is false', async () => {
await mountAdditionalFonts(document, false);
const styles = document.head.querySelectorAll('style');
expect(styles.length).toBe(0);
const links = document.head.querySelectorAll('link');
const hrefs = Array.from(links).map((l) => l.getAttribute('href') || '');
expect(hrefs.some((h) => h.includes('jsdelivr.net'))).toBe(false);
});
it('should mount CJK fonts when isCJK is true', async () => {
await mountAdditionalFonts(document, true);
// Should have a style element with @font-face rules
const styles = document.head.querySelectorAll('style');
expect(styles.length).toBeGreaterThanOrEqual(1);
const styleContent = styles[0]!.textContent || '';
expect(styleContent).toContain('@font-face');
expect(styleContent).toContain('FangSong');
expect(styleContent).toContain('Kaiti');
expect(styleContent).toContain('Heiti');
expect(styleContent).toContain('XiHeiti');
// Should have CJK-specific link tags
const links = document.head.querySelectorAll('link');
const hrefs = Array.from(links).map((l) => l.getAttribute('href') || '');
expect(hrefs.some((h) => h.includes('jsdelivr.net'))).toBe(true);
});
it('should mount CJK fonts when isCJKEnv returns true', async () => {
vi.mocked(isCJKEnv).mockReturnValue(true);
await mountAdditionalFonts(document);
const styles = document.head.querySelectorAll('style');
expect(styles.length).toBeGreaterThanOrEqual(1);
const styleContent = styles[0]!.textContent || '';
expect(styleContent).toContain('@font-face');
});
it('should mount CJK fonts when either isCJK param or isCJKEnv is true', async () => {
vi.mocked(isCJKEnv).mockReturnValue(false);
await mountAdditionalFonts(document, true);
const styles = document.head.querySelectorAll('style');
expect(styles.length).toBeGreaterThanOrEqual(1);
});
});
describe('mountCustomFont', () => {
const baseFont: CustomFont = {
id: 'test-font-id',
name: 'TestFont',
path: '/fonts/TestFont.ttf',
blobUrl: 'blob:http://localhost/abc123',
};
beforeEach(() => {
document.head.innerHTML = '';
});
it('should create a style element with the correct id', () => {
mountCustomFont(document, baseFont);
const style = document.getElementById('custom-font-test-font-id');
expect(style).not.toBeNull();
expect(style!.tagName).toBe('STYLE');
});
it('should set textContent to the generated CSS', () => {
mountCustomFont(document, baseFont);
const style = document.getElementById('custom-font-test-font-id');
expect(style!.textContent).toContain('@font-face');
expect(style!.textContent).toContain('font-family: "TestFont"');
expect(style!.textContent).toContain('blob:http://localhost/abc123');
});
it('should append the style element to document head', () => {
mountCustomFont(document, baseFont);
expect(document.head.children.length).toBe(1);
expect(document.head.children[0]!.id).toBe('custom-font-test-font-id');
});
it('should update existing style element instead of creating a duplicate', () => {
mountCustomFont(document, baseFont);
expect(document.head.querySelectorAll('style').length).toBe(1);
const updatedFont: CustomFont = {
...baseFont,
blobUrl: 'blob:http://localhost/updated',
};
mountCustomFont(document, updatedFont);
// Still only one style element
expect(document.head.querySelectorAll('style').length).toBe(1);
const style = document.getElementById('custom-font-test-font-id');
expect(style!.textContent).toContain('blob:http://localhost/updated');
});
it('should handle multiple different fonts', () => {
const font2: CustomFont = {
id: 'other-font-id',
name: 'OtherFont',
path: '/fonts/OtherFont.otf',
blobUrl: 'blob:http://localhost/other',
};
mountCustomFont(document, baseFont);
mountCustomFont(document, font2);
expect(document.head.querySelectorAll('style').length).toBe(2);
expect(document.getElementById('custom-font-test-font-id')).not.toBeNull();
expect(document.getElementById('custom-font-other-font-id')).not.toBeNull();
});
it('should throw when font has no blobUrl', () => {
const fontNoBlobUrl: CustomFont = { ...baseFont, blobUrl: undefined };
expect(() => mountCustomFont(document, fontNoBlobUrl)).toThrow(
'Blob URL not available for font: TestFont',
);
});
});