From d4cf6764b42aadb801de8628a6b94cbc4ab3755d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 4 Apr 2026 10:38:20 +0300 Subject: [PATCH] test(appliedFontsStore): rewrite tests with describe grouping and full coverage --- .../appliedFontStore.test.ts | 354 +++++++++++++----- 1 file changed, 259 insertions(+), 95 deletions(-) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts index 01dad3a..bba32a5 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts @@ -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 { 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 { + 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; delete: ReturnType }; 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 { + 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); + }); }); });