refactor/code-splitting #31

Merged
ilia merged 32 commits from refactor/code-splitting into main 2026-04-08 06:34:20 +00:00
Showing only changes of commit d4cf6764b4 - Show all commits

View File

@@ -1,15 +1,10 @@
/** @vitest-environment jsdom */
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { AppliedFontsManager } from './appliedFontsStore.svelte';
import { FontFetchError } from './errors';
import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy';
// ── Fake collaborators ────────────────────────────────────────────────────────
class FakeBufferCache {
async get(_url: string): Promise<ArrayBuffer> {
return new ArrayBuffer(8);
@@ -18,29 +13,36 @@ class FakeBufferCache {
clear(): void {}
}
/** Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. */
class FailingBufferCache {
async get(url: string): Promise<never> {
throw new FontFetchError(url, new Error('network error'), 500);
}
evict(_url: string): void {}
clear(): void {}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
id,
name: id,
url: `https://example.com/${id}.woff2`,
weight: 400,
...overrides,
});
// ── Suite ─────────────────────────────────────────────────────────────────────
describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager;
let mockFontFaceSet: any;
let fakeEviction: FontEvictionPolicy;
let eviction: FontEvictionPolicy;
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.useFakeTimers();
fakeEviction = new FontEvictionPolicy({ ttl: 60000 });
mockFontFaceSet = {
add: vi.fn(),
delete: vi.fn(),
};
const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
this.name = name;
this.bufferOrUrl = bufferOrUrl;
this.load = vi.fn().mockImplementation(() => {
return Promise.resolve(this);
});
});
vi.stubGlobal('FontFace', MockFontFace);
eviction = new FontEvictionPolicy({ ttl: 60000 });
mockFontFaceSet = { add: vi.fn(), delete: vi.fn() };
Object.defineProperty(document, 'fonts', {
value: mockFontFaceSet,
@@ -48,11 +50,14 @@ describe('AppliedFontsManager', () => {
writable: true,
});
vi.stubGlobal('crypto', {
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
const MockFontFace = vi.fn(function(this: any, name: string, buffer: BufferSource) {
this.name = name;
this.buffer = buffer;
this.load = vi.fn().mockResolvedValue(this);
});
vi.stubGlobal('FontFace', MockFontFace);
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction: fakeEviction });
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
});
afterEach(() => {
@@ -61,108 +66,267 @@ describe('AppliedFontsManager', () => {
vi.unstubAllGlobals();
});
it('should batch multiple font requests into a single process', async () => {
const configs = [
{ id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 },
{ id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 },
];
// ── touch() ───────────────────────────────────────────────────────────────
manager.touch(configs);
describe('touch()', () => {
it('queues and loads a new font', async () => {
manager.touch([makeConfig('roboto')]);
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('roboto', 400)).toBe('loaded');
});
expect(manager.getFontStatus('lato-400', 400)).toBe('loaded');
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
it('batches multiple fonts into a single queue flush', async () => {
manager.touch([makeConfig('lato'), makeConfig('inter')]);
await vi.advanceTimersByTimeAsync(50);
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
});
it('skips fonts that are already loaded', async () => {
manager.touch([makeConfig('lato')]);
await vi.advanceTimersByTimeAsync(50);
manager.touch([makeConfig('lato')]);
await vi.advanceTimersByTimeAsync(50);
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
});
it('skips fonts that are currently loading', async () => {
manager.touch([makeConfig('lato')]);
// simulate loading state before queue drains
manager.statuses.set('lato@400', 'loading');
manager.touch([makeConfig('lato')]);
await vi.advanceTimersByTimeAsync(50);
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
});
it('skips fonts that have exhausted retries', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
// exhaust all 3 retries
for (let i = 0; i < 3; i++) {
failManager.statuses.delete('broken@400');
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
}
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(failManager.getFontStatus('broken', 400)).toBe('error');
expect(mockFontFaceSet.add).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('does nothing after manager is destroyed', async () => {
manager.destroy();
manager.touch([makeConfig('roboto')]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.statuses.size).toBe(0);
});
});
it('should purge fonts after TTL expires', async () => {
const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
// ── queue processing ──────────────────────────────────────────────────────
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded');
describe('queue processing', () => {
it('filters non-critical weights in data-saver mode', async () => {
(navigator as any).connection = { saveData: true };
await vi.advanceTimersByTimeAsync(61000);
manager.touch([
makeConfig('light', { weight: 300 }),
makeConfig('regular', { weight: 400 }),
makeConfig('bold', { weight: 700 }),
]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
expect(mockFontFaceSet.delete).toHaveBeenCalled();
expect(manager.getFontStatus('light', 300)).toBeUndefined();
expect(manager.getFontStatus('regular', 400)).toBe('loaded');
expect(manager.getFontStatus('bold', 700)).toBe('loaded');
delete (navigator as any).connection;
});
it('loads variable fonts in data-saver mode regardless of weight', async () => {
(navigator as any).connection = { saveData: true };
manager.touch([makeConfig('vf', { weight: 300, isVariable: true })]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('vf', 300, true)).toBe('loaded');
delete (navigator as any).connection;
});
});
it('should NOT purge fonts that are still being "touched"', async () => {
const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
// ── Phase 1: fetch ────────────────────────────────────────────────────────
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
describe('Phase 1 — fetch', () => {
it('sets status to error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
await vi.advanceTimersByTimeAsync(40000);
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
manager.touch([config]);
expect(failManager.getFontStatus('broken', 400)).toBe('error');
consoleSpy.mockRestore();
});
await vi.advanceTimersByTimeAsync(20000);
it('logs a console error on fetch failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
expect(manager.getFontStatus('active', 400)).toBe('loaded');
failManager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('does not set error status or log for aborted fetches', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const abortingCache = {
async get(url: string): Promise<never> {
throw new FontFetchError(url, Object.assign(new Error('Aborted'), { name: 'AbortError' }));
},
evict() {},
clear() {},
};
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction });
abortManager.touch([makeConfig('aborted')]);
await vi.advanceTimersByTimeAsync(50);
// status is left as 'loading' (not 'error') — abort is not a retriable failure
expect(abortManager.getFontStatus('aborted', 400)).not.toBe('error');
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
it('should serve buffer from memory without calling fetch again', async () => {
const config = { id: 'cached', name: 'Cached', url: 'https://example.com/cached.ttf', weight: 400 };
// ── Phase 2: parse ────────────────────────────────────────────────────────
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
describe('Phase 2 — parse', () => {
it('sets status to error on parse failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const FailingFontFace = vi.fn(function(this: any) {
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
});
vi.stubGlobal('FontFace', FailingFontFace);
manager.statuses.delete('cached@400');
manager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('broken', 400)).toBe('error');
consoleSpy.mockRestore();
});
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
it('logs a console error on parse failure', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const FailingFontFace = vi.fn(function(this: any) {
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
});
vi.stubGlobal('FontFace', FailingFontFace);
manager.touch([makeConfig('broken')]);
await vi.advanceTimersByTimeAsync(50);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
it('should NOT purge a pinned font after TTL expires', async () => {
const config = { id: 'pinned', name: 'Pinned', url: 'https://example.com/pinned.ttf', weight: 400 };
// ── #purgeUnused ──────────────────────────────────────────────────────────
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
describe('#purgeUnused', () => {
it('evicts fonts after TTL expires', async () => {
manager.touch([makeConfig('ephemeral')]);
await vi.advanceTimersByTimeAsync(50);
manager.pin('pinned', 400);
await vi.advanceTimersByTimeAsync(61000);
await vi.advanceTimersByTimeAsync(61000);
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
expect(mockFontFaceSet.delete).toHaveBeenCalled();
});
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
it('removes the evicted key from the eviction policy', async () => {
manager.touch([makeConfig('ephemeral')]);
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(61000);
expect(Array.from(eviction.keys())).not.toContain('ephemeral@400');
});
it('refreshes TTL when font is re-touched before expiry', async () => {
const config = makeConfig('active');
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
await vi.advanceTimersByTimeAsync(40000);
manager.touch([config]); // refresh at t≈40s
await vi.advanceTimersByTimeAsync(25000); // purge at t≈60s sees only ~20s elapsed → not evicted
expect(manager.getFontStatus('active', 400)).toBe('loaded');
});
it('does not evict pinned fonts', async () => {
manager.touch([makeConfig('pinned')]);
await vi.advanceTimersByTimeAsync(50);
manager.pin('pinned', 400);
await vi.advanceTimersByTimeAsync(61000);
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
});
it('evicts font after it is unpinned and TTL expires', async () => {
manager.touch([makeConfig('toggled')]);
await vi.advanceTimersByTimeAsync(50);
manager.pin('toggled', 400);
manager.unpin('toggled', 400);
await vi.advanceTimersByTimeAsync(61000);
expect(manager.getFontStatus('toggled', 400)).toBeUndefined();
expect(mockFontFaceSet.delete).toHaveBeenCalled();
});
});
it('should evict a font after it is unpinned and TTL expires', async () => {
const config = { id: 'unpinned', name: 'Unpinned', url: 'https://example.com/unpinned.ttf', weight: 400 };
// ── destroy() ─────────────────────────────────────────────────────────────
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.getFontStatus('unpinned', 400)).toBe('loaded');
describe('destroy()', () => {
it('clears all statuses', async () => {
manager.touch([makeConfig('roboto')]);
await vi.advanceTimersByTimeAsync(50);
manager.pin('unpinned', 400);
manager.unpin('unpinned', 400);
manager.destroy();
await vi.advanceTimersByTimeAsync(61000);
expect(manager.statuses.size).toBe(0);
});
expect(manager.getFontStatus('unpinned', 400)).toBeUndefined();
expect(mockFontFaceSet.delete).toHaveBeenCalled();
});
it('removes all loaded fonts from document.fonts', async () => {
manager.touch([makeConfig('roboto'), makeConfig('inter')]);
await vi.advanceTimersByTimeAsync(50);
it('should clear pinned set on destroy without errors', async () => {
const config = {
id: 'destroy-pin',
name: 'DestroyPin',
url: 'https://example.com/destroypin.ttf',
weight: 400,
};
manager.destroy();
manager.touch([config]);
await vi.advanceTimersByTimeAsync(50);
expect(mockFontFaceSet.delete).toHaveBeenCalledTimes(2);
});
manager.pin('destroy-pin', 400);
manager.destroy();
it('prevents further loading after destroy', async () => {
manager.destroy();
manager.touch([makeConfig('roboto')]);
await vi.advanceTimersByTimeAsync(50);
expect(manager.statuses.size).toBe(0);
expect(manager.statuses.size).toBe(0);
});
});
});