forked from akai/readest
981579c255
* 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>
474 lines
15 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|