/** * @vitest-environment jsdom */ import { FontFetchError } from './errors'; import { FontLifecycleManager } from './fontLifecycleManager.svelte'; import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy'; class FakeBufferCache { async get(_url: string): Promise { return new ArrayBuffer(8); } evict(_url: string): void {} clear(): void {} } /** * Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. */ class FailingBufferCache { async get(url: string): Promise { throw new FontFetchError(url, new Error('network error'), 500); } evict(_url: string): void {} clear(): void {} } const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({ id, name: id, url: `https://example.com/${id}.woff2`, weight: 400, ...overrides, }); describe('FontLifecycleManager', () => { let manager: FontLifecycleManager; let eviction: FontEvictionPolicy; let mockFontFaceSet: { add: ReturnType; delete: ReturnType }; beforeEach(() => { vi.useFakeTimers(); eviction = new FontEvictionPolicy({ ttl: 60000 }); mockFontFaceSet = { add: vi.fn(), delete: vi.fn() }; Object.defineProperty(document, 'fonts', { value: mockFontFaceSet, configurable: true, writable: true, }); 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 FontLifecycleManager({ cache: new FakeBufferCache() as any, eviction }); }); afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); vi.unstubAllGlobals(); }); describe('touch()', () => { it('queues and loads a new font', async () => { manager.touch([makeConfig('roboto')]); await vi.advanceTimersByTimeAsync(50); expect(manager.getFontStatus('roboto', 400)).toBe('loaded'); }); 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 FontLifecycleManager({ 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); }); }); describe('queue processing', () => { it('filters non-critical weights in data-saver mode', async () => { (navigator as any).connection = { saveData: true }; manager.touch([ makeConfig('light', { weight: 300 }), makeConfig('regular', { weight: 400 }), makeConfig('bold', { weight: 700 }), ]); await vi.advanceTimersByTimeAsync(50); 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; }); }); describe('Phase 1 — fetch', () => { it('sets status to error on fetch failure', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction }); failManager.touch([makeConfig('broken')]); await vi.advanceTimersByTimeAsync(50); expect(failManager.getFontStatus('broken', 400)).toBe('error'); consoleSpy.mockRestore(); }); it('logs a console error on fetch failure', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const failManager = new FontLifecycleManager({ cache: new FailingBufferCache() as any, eviction }); 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 { throw new FontFetchError(url, Object.assign(new Error('Aborted'), { name: 'AbortError' })); }, evict() {}, clear() {}, }; const abortManager = new FontLifecycleManager({ 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(); }); }); 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.touch([makeConfig('broken')]); await vi.advanceTimersByTimeAsync(50); expect(manager.getFontStatus('broken', 400)).toBe('error'); consoleSpy.mockRestore(); }); 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(); }); }); describe('#purgeUnused', () => { it('evicts fonts after TTL expires', async () => { manager.touch([makeConfig('ephemeral')]); await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(61000); expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined(); expect(mockFontFaceSet.delete).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(); }); }); describe('destroy()', () => { it('clears all statuses', async () => { manager.touch([makeConfig('roboto')]); await vi.advanceTimersByTimeAsync(50); manager.destroy(); expect(manager.statuses.size).toBe(0); }); it('removes all loaded fonts from document.fonts', async () => { manager.touch([makeConfig('roboto'), makeConfig('inter')]); await vi.advanceTimersByTimeAsync(50); manager.destroy(); expect(mockFontFaceSet.delete).toHaveBeenCalledTimes(2); }); it('prevents further loading after destroy', async () => { manager.destroy(); manager.touch([makeConfig('roboto')]); await vi.advanceTimersByTimeAsync(50); expect(manager.statuses.size).toBe(0); }); }); });