diff --git a/index.html b/index.html index ddd2d2c..1772631 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ glyphdiff +
diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index f23ed08..f4f0782 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -103,6 +103,11 @@ export { UNIFIED_FONTS, } from './lib/mocks'; +export { + FontNetworkError, + FontResponseError, +} from './lib/errors/errors'; + // UI elements export { FontApplicator, diff --git a/src/entities/Font/lib/errors/errors.test.ts b/src/entities/Font/lib/errors/errors.test.ts new file mode 100644 index 0000000..a24c000 --- /dev/null +++ b/src/entities/Font/lib/errors/errors.test.ts @@ -0,0 +1,51 @@ +import { + FontNetworkError, + FontResponseError, +} from './errors'; + +describe('FontNetworkError', () => { + it('has correct name', () => { + const err = new FontNetworkError(); + expect(err.name).toBe('FontNetworkError'); + }); + + it('is instance of Error', () => { + expect(new FontNetworkError()).toBeInstanceOf(Error); + }); + + it('stores cause', () => { + const cause = new Error('network down'); + const err = new FontNetworkError(cause); + expect(err.cause).toBe(cause); + }); + + it('has default message', () => { + expect(new FontNetworkError().message).toBe('Failed to fetch fonts from proxy API'); + }); +}); + +describe('FontResponseError', () => { + it('has correct name', () => { + const err = new FontResponseError('response', undefined); + expect(err.name).toBe('FontResponseError'); + }); + + it('is instance of Error', () => { + expect(new FontResponseError('response.fonts', null)).toBeInstanceOf(Error); + }); + + it('stores field', () => { + const err = new FontResponseError('response.fonts', 42); + expect(err.field).toBe('response.fonts'); + }); + + it('stores received value', () => { + const err = new FontResponseError('response.fonts', 42); + expect(err.received).toBe(42); + }); + + it('message includes field name', () => { + const err = new FontResponseError('response.fonts', null); + expect(err.message).toContain('response.fonts'); + }); +}); diff --git a/src/entities/Font/lib/errors/errors.ts b/src/entities/Font/lib/errors/errors.ts new file mode 100644 index 0000000..4a49f96 --- /dev/null +++ b/src/entities/Font/lib/errors/errors.ts @@ -0,0 +1,28 @@ +/** + * Thrown when the network request to the proxy API fails. + * Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.). + */ +export class FontNetworkError extends Error { + readonly name = 'FontNetworkError'; + + constructor(public readonly cause?: unknown) { + super('Failed to fetch fonts from proxy API'); + } +} + +/** + * Thrown when the proxy API returns a response with an unexpected shape. + * + * @property field - The name of the field that failed validation (e.g. `'response'`, `'response.fonts'`). + * @property received - The actual value received at that field, for debugging. + */ +export class FontResponseError extends Error { + readonly name = 'FontResponseError'; + + constructor( + public readonly field: string, + public readonly received: unknown, + ) { + super(`Invalid proxy API response: ${field}`); + } +} diff --git a/src/entities/Font/lib/index.ts b/src/entities/Font/lib/index.ts index 532d5b8..7445d2a 100644 --- a/src/entities/Font/lib/index.ts +++ b/src/entities/Font/lib/index.ts @@ -56,3 +56,8 @@ export { type MockUnifiedFontOptions, UNIFIED_FONTS, } from './mocks'; + +export { + FontNetworkError, + FontResponseError, +} from './errors/errors'; diff --git a/src/entities/Font/model/index.ts b/src/entities/Font/model/index.ts index 8ec6e69..90eb6e3 100644 --- a/src/entities/Font/model/index.ts +++ b/src/entities/Font/model/index.ts @@ -8,6 +8,8 @@ export type { FontFeatures, FontFiles, FontItem, + FontLoadRequestConfig, + FontLoadStatus, FontMetadata, FontProvider, // Fontshare API types @@ -37,7 +39,6 @@ export type { export { appliedFontsManager, createUnifiedFontStore, - type FontConfigRequest, type UnifiedFontStore, unifiedFontStore, } from './store'; diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts index e0eec49..bac5059 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts @@ -1,73 +1,63 @@ /** @vitest-environment jsdom */ -import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; import { AppliedFontsManager } from './appliedFontsStore.svelte'; +import { FontFetchError } from './errors'; +import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy'; + +// ── Fake collaborators ──────────────────────────────────────────────────────── + +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 {} +} + +// ── 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 mockFetch: any; - let failUrls: Set; + let eviction: FontEvictionPolicy; + let mockFontFaceSet: { add: ReturnType; delete: ReturnType }; beforeEach(() => { vi.useFakeTimers(); - failUrls = new Set(); + eviction = new FontEvictionPolicy({ ttl: 60000 }); + mockFontFaceSet = { add: vi.fn(), delete: vi.fn() }; - mockFontFaceSet = { - add: vi.fn(), - delete: vi.fn(), - }; - - // 1. Properly mock FontFace as a constructor function - // The actual implementation passes buffer (ArrayBuffer) as second arg, not URL string - const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) { - this.name = name; - this.bufferOrUrl = bufferOrUrl; - this.load = vi.fn().mockImplementation(() => { - // For error tests, we track which URLs should fail via failUrls - // The fetch mock will have already rejected for those URLs - return Promise.resolve(this); - }); - }); - - vi.stubGlobal('FontFace', MockFontFace); - - // 2. Mock document.fonts safely Object.defineProperty(document, 'fonts', { value: mockFontFaceSet, configurable: true, 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); - // 3. Mock fetch to return fake ArrayBuffer data - mockFetch = vi.fn((url: string) => { - if (failUrls.has(url)) { - return Promise.reject(new Error('Network error')); - } - return Promise.resolve({ - ok: true, - status: 200, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), - clone: () => ({ - ok: true, - status: 200, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), - }), - } as Response); - }); - vi.stubGlobal('fetch', mockFetch); - - manager = new AppliedFontsManager(); + manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction }); }); afterEach(() => { @@ -76,138 +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); - // Advance to trigger the 16ms debounced #processQueue - 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 handle font loading errors gracefully', async () => { - // Suppress expected console error for clean test logs - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // ── queue processing ────────────────────────────────────────────────────── - const failUrl = 'https://example.com/fail.ttf'; - failUrls.add(failUrl); + describe('queue processing', () => { + it('filters non-critical weights in data-saver mode', async () => { + (navigator as any).connection = { saveData: true }; - const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 }; + manager.touch([ + makeConfig('light', { weight: 300 }), + makeConfig('regular', { weight: 400 }), + makeConfig('bold', { weight: 700 }), + ]); + await vi.advanceTimersByTimeAsync(50); - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); + expect(manager.getFontStatus('light', 300)).toBeUndefined(); + expect(manager.getFontStatus('regular', 400)).toBe('loaded'); + expect(manager.getFontStatus('bold', 700)).toBe('loaded'); - expect(manager.getFontStatus('broken', 400)).toBe('error'); - spy.mockRestore(); + 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 purge fonts after TTL expires', async () => { - const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 }; + // ── Phase 1: fetch ──────────────────────────────────────────────────────── - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); - expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded'); + 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 }); - // Move clock forward past TTL (5m) and Purge Interval (1m) - // advanceTimersByTimeAsync is key here; it handles the promises inside the interval - await vi.advanceTimersByTimeAsync(6 * 60 * 1000); + failManager.touch([makeConfig('broken')]); + await vi.advanceTimersByTimeAsync(50); - expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined(); - expect(mockFontFaceSet.delete).toHaveBeenCalled(); + 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 AppliedFontsManager({ 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 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 NOT purge fonts that are still being "touched"', async () => { - const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 }; + // ── Phase 2: parse ──────────────────────────────────────────────────────── - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); + 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); - // Advance 4 minutes - await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + manager.touch([makeConfig('broken')]); + await vi.advanceTimersByTimeAsync(50); - // Refresh touch - manager.touch([config]); + expect(manager.getFontStatus('broken', 400)).toBe('error'); + consoleSpy.mockRestore(); + }); - // Advance another 2 minutes (Total 6 since start) - await vi.advanceTimersByTimeAsync(2 * 60 * 1000); + 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); - expect(manager.getFontStatus('active', 400)).toBe('loaded'); + manager.touch([makeConfig('broken')]); + await vi.advanceTimersByTimeAsync(50); + + expect(consoleSpy).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 }; + // ── #purgeUnused ────────────────────────────────────────────────────────── - // First load — populates in-memory buffer - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); - expect(manager.getFontStatus('cached', 400)).toBe('loaded'); - expect(mockFetch).toHaveBeenCalledTimes(1); + describe('#purgeUnused', () => { + it('evicts fonts after TTL expires', async () => { + manager.touch([makeConfig('ephemeral')]); + await vi.advanceTimersByTimeAsync(50); - // Simulate eviction by deleting the status entry directly - manager.statuses.delete('cached@400'); + await vi.advanceTimersByTimeAsync(61000); - // Second load — should hit in-memory buffer, not network - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); + expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined(); + expect(mockFontFaceSet.delete).toHaveBeenCalled(); + }); - expect(manager.getFontStatus('cached', 400)).toBe('loaded'); - // fetch should still only have been called once (buffer was reused) - expect(mockFetch).toHaveBeenCalledTimes(1); + 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 NOT purge a pinned font after TTL expires', async () => { - const config = { id: 'pinned', name: 'Pinned', url: 'https://example.com/pinned.ttf', weight: 400 }; + // ── destroy() ───────────────────────────────────────────────────────────── - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); - expect(manager.getFontStatus('pinned', 400)).toBe('loaded'); + describe('destroy()', () => { + it('clears all statuses', async () => { + manager.touch([makeConfig('roboto')]); + await vi.advanceTimersByTimeAsync(50); - manager.pin('pinned', 400); + manager.destroy(); - // Advance past TTL + purge interval - await vi.advanceTimersByTimeAsync(6 * 60 * 1000); + expect(manager.statuses.size).toBe(0); + }); - expect(manager.getFontStatus('pinned', 400)).toBe('loaded'); - expect(mockFontFaceSet.delete).not.toHaveBeenCalled(); - }); + it('removes all loaded fonts from document.fonts', async () => { + manager.touch([makeConfig('roboto'), makeConfig('inter')]); + await vi.advanceTimersByTimeAsync(50); - 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 }; + manager.destroy(); - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); - expect(manager.getFontStatus('unpinned', 400)).toBe('loaded'); + expect(mockFontFaceSet.delete).toHaveBeenCalledTimes(2); + }); - manager.pin('unpinned', 400); - manager.unpin('unpinned', 400); + it('prevents further loading after destroy', async () => { + manager.destroy(); + manager.touch([makeConfig('roboto')]); + await vi.advanceTimersByTimeAsync(50); - // Advance past TTL + purge interval - await vi.advanceTimersByTimeAsync(6 * 60 * 1000); - - expect(manager.getFontStatus('unpinned', 400)).toBeUndefined(); - expect(mockFontFaceSet.delete).toHaveBeenCalled(); - }); - - 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.touch([config]); - await vi.advanceTimersByTimeAsync(50); - - manager.pin('destroy-pin', 400); - manager.destroy(); - - expect(manager.statuses.size).toBe(0); + expect(manager.statuses.size).toBe(0); + }); }); }); diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 17e58cf..f0ff3cd 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -1,30 +1,26 @@ import { SvelteMap } from 'svelte/reactivity'; +import { + type FontLoadRequestConfig, + type FontLoadStatus, +} from '../../types'; +import { + FontFetchError, + FontParseError, +} from './errors'; +import { + generateFontKey, + getEffectiveConcurrency, + loadFont, + yieldToMainThread, +} from './utils'; +import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache'; +import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy'; +import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue'; -/** Loading state of a font. Failed loads may be retried up to MAX_RETRIES. */ -export type FontStatus = 'loading' | 'loaded' | 'error'; - -/** Configuration for a font load request. */ -export interface FontConfigRequest { - /** - * Unique identifier for the font (e.g., "lato", "roboto"). - */ - id: string; - /** - * Actual font family name recognized by the browser (e.g., "Lato", "Roboto"). - */ - name: string; - /** - * URL pointing to the font file (typically .ttf or .woff2). - */ - url: string; - /** - * Numeric weight (100-900). Variable fonts load once per ID regardless of weight. - */ - weight: number; - /** - * Variable fonts load once per ID; static fonts load per weight. - */ - isVariable?: boolean; +interface AppliedFontsManagerDeps { + cache?: FontBufferCache; + eviction?: FontEvictionPolicy; + queue?: FontLoadQueue; } /** @@ -51,14 +47,16 @@ export interface FontConfigRequest { * **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API */ export class AppliedFontsManager { + // Injected collaborators - each handles one concern for better testability + readonly #cache: FontBufferCache; + readonly #eviction: FontEvictionPolicy; + readonly #queue: FontLoadQueue; + // Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf` #loadedFonts = new Map(); - // Last-used timestamps for LRU cleanup. Key: `{id}@{weight}` or `{id}@vf`, Value: unix timestamp (ms) - #usageTracker = new Map(); - - // Fonts queued for loading by `touch()`, processed by `#processQueue()` - #queue = new Map(); + // Maps font key → URL so #purgeUnused() can evict from cache + #urlByKey = new Map(); // Handle for scheduled queue processing (requestIdleCallback or setTimeout) #timeoutId: ReturnType | null = null; @@ -72,112 +70,95 @@ export class AppliedFontsManager { // Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation #pendingType: 'idle' | 'timeout' | null = null; - // Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped - #retryCounts = new Map(); - - // In-memory buffer cache keyed by URL — fastest tier, checked before Cache API and network - #buffersByUrl = new Map(); - - // Maps font key → URL so #purgeUnused() can evict from #buffersByUrl - #urlByKey = new Map(); - - // Fonts currently visible/in-use; purge skips these regardless of TTL - #pinnedFonts = new Set(); - - readonly #MAX_RETRIES = 3; - readonly #PURGE_INTERVAL = 60000; // 60 seconds - readonly #TTL = 5 * 60 * 1000; // 5 minutes - readonly #CACHE_NAME = 'font-cache-v1'; // Versioned for future invalidation + readonly #PURGE_INTERVAL = 60000; // Reactive status map for Svelte components to track font states - statuses = new SvelteMap(); + statuses = new SvelteMap(); // Starts periodic cleanup timer (browser-only). - constructor() { + constructor( + { cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }: + AppliedFontsManagerDeps = {}, + ) { + // Inject collaborators - defaults provided for production, fakes for testing + this.#cache = cache; + this.#eviction = eviction; + this.#queue = queue; if (typeof window !== 'undefined') { this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL); } } - // Generates font key: `{id}@vf` for variable, `{id}@{weight}` for static. - #getFontKey(id: string, weight: number, isVariable: boolean): string { - return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`; - } - /** * Requests fonts to be loaded. Updates usage tracking and queues new fonts. * * Retry behavior: 'loaded' and 'loading' fonts are skipped; 'error' fonts retry if count < MAX_RETRIES. * Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms). */ - touch(configs: FontConfigRequest[]) { - if (this.#abortController.signal.aborted) return; - - const now = Date.now(); - let hasNewItems = false; - - for (const config of configs) { - const key = this.#getFontKey(config.id, config.weight, !!config.isVariable); - this.#usageTracker.set(key, now); - - const status = this.statuses.get(key); - if (status === 'loaded' || status === 'loading' || this.#queue.has(key)) continue; - if (status === 'error' && (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES) continue; - - this.#queue.set(key, config); - hasNewItems = true; + touch(configs: FontLoadRequestConfig[]) { + if (this.#abortController.signal.aborted) { + return; } + try { + const now = Date.now(); + let hasNewItems = false; - if (hasNewItems && !this.#timeoutId) { - if (typeof requestIdleCallback !== 'undefined') { - this.#timeoutId = requestIdleCallback( - () => this.#processQueue(), - { timeout: 150 }, - ) as unknown as ReturnType; - this.#pendingType = 'idle'; - } else { - this.#timeoutId = setTimeout(() => this.#processQueue(), 16); - this.#pendingType = 'timeout'; + for (const config of configs) { + const key = generateFontKey(config); + + // Update last-used timestamp for LRU eviction policy + this.#eviction.touch(key, now); + + const status = this.statuses.get(key); + + // Skip fonts that are already loaded or currently loading + if (status === 'loaded' || status === 'loading') { + continue; + } + + // Skip fonts already in the queue (avoid duplicates) + if (this.#queue.has(key)) { + continue; + } + + // Skip error fonts that have exceeded max retry count + if (status === 'error' && this.#queue.isMaxRetriesReached(key)) { + continue; + } + + // Queue this font for loading + this.#queue.enqueue(key, config); + hasNewItems = true; } + + if (hasNewItems && !this.#timeoutId) { + this.#scheduleProcessing(); + } + } catch (error) { + console.error(error); } } - /** Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() (Chrome/Edge) or MessageChannel fallback. */ - async #yieldToMain(): Promise { - // @ts-expect-error - scheduler not in TypeScript lib yet - if (typeof scheduler !== 'undefined' && 'yield' in scheduler) { - // @ts-expect-error - scheduler.yield not in TypeScript lib yet - await scheduler.yield(); + /** + * Schedules `#processQueue()` via `requestIdleCallback` (150ms timeout) when available, + * falling back to `setTimeout(16ms)` for ~60fps timing. + */ + #scheduleProcessing(): void { + if (typeof requestIdleCallback !== 'undefined') { + this.#timeoutId = requestIdleCallback( + () => this.#processQueue(), + { timeout: 150 }, + ) as unknown as ReturnType; + this.#pendingType = 'idle'; } else { - await new Promise(resolve => { - const ch = new MessageChannel(); - ch.port1.onmessage = () => resolve(); - ch.port2.postMessage(null); - }); - } - } - - /** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */ - #getEffectiveConcurrency(): number { - const nav = navigator as any; - const conn = nav.connection; - if (!conn) return 4; - - switch (conn.effectiveType) { - case 'slow-2g': - case '2g': - return 1; - case '3g': - return 2; - default: - return 4; + this.#timeoutId = setTimeout(() => this.#processQueue(), 16); + this.#pendingType = 'timeout'; } } /** Returns true if data-saver mode is enabled (defers non-critical weights). */ #shouldDeferNonCritical(): boolean { - const nav = navigator as any; - return nav.connection?.saveData === true; + return (navigator as any).connection?.saveData === true; } /** @@ -188,173 +169,179 @@ export class AppliedFontsManager { * Yielding: Chromium uses `isInputPending()` for optimal responsiveness; others yield every 8ms. */ async #processQueue() { + // Clear timer flags since we're now processing this.#timeoutId = null; this.#pendingType = null; - let entries = Array.from(this.#queue.entries()); - if (!entries.length) return; - this.#queue.clear(); + // Get all queued entries and clear the queue atomically + let entries = this.#queue.flush(); + if (!entries.length) { + return; + } + // In data-saver mode, only load variable fonts and common weights (400, 700) if (this.#shouldDeferNonCritical()) { entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight)); } - // Phase 1: Concurrent fetching (I/O bound, non-blocking) - const concurrency = this.#getEffectiveConcurrency(); + // Determine optimal concurrent fetches based on network speed (1-4) + const concurrency = getEffectiveConcurrency(); const buffers = new Map(); + // ==================== PHASE 1: Concurrent Fetching ==================== + // Fetch multiple font files in parallel since network I/O is non-blocking for (let i = 0; i < entries.length; i += concurrency) { - const chunk = entries.slice(i, i + concurrency); - const results = await Promise.allSettled( - chunk.map(async ([key, config]) => { - this.statuses.set(key, 'loading'); - const buffer = await this.#fetchFontBuffer( - config.url, - this.#abortController.signal, - ); - buffers.set(key, buffer); - }), - ); - - for (let j = 0; j < results.length; j++) { - if (results[j].status === 'rejected') { - const [key, config] = chunk[j]; - console.error(`Font fetch failed: ${config.name}`, (results[j] as PromiseRejectedResult).reason); - this.statuses.set(key, 'error'); - this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1); - } - } + await this.#fetchChunk(entries.slice(i, i + concurrency), buffers); } - // Phase 2: Sequential parsing (CPU-intensive, yields periodically) + // ==================== PHASE 2: Sequential Parsing ==================== + // Parse buffers one at a time with periodic yields to avoid blocking UI const hasInputPending = !!(navigator as any).scheduling?.isInputPending; let lastYield = performance.now(); - const YIELD_INTERVAL = 8; // ms + const YIELD_INTERVAL = 8; for (const [key, config] of entries) { const buffer = buffers.get(key); - if (!buffer) continue; - - try { - const weightRange = config.isVariable ? '100 900' : `${config.weight}`; - const font = new FontFace(config.name, buffer, { - weight: weightRange, - style: 'normal', - display: 'swap', - }); - await font.load(); - document.fonts.add(font); - this.#loadedFonts.set(key, font); - this.#buffersByUrl.set(config.url, buffer); - this.#urlByKey.set(key, config.url); - this.statuses.set(key, 'loaded'); - } catch (e) { - if (e instanceof Error && e.name === 'AbortError') continue; - console.error(`Font parse failed: ${config.name}`, e); - this.statuses.set(key, 'error'); - this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1); + // Skip fonts that failed to fetch in phase 1 + if (!buffer) { + continue; } + await this.#processFont(key, config, buffer); + + // Yield to main thread if needed (prevents UI blocking) + // Chromium: use isInputPending() for optimal responsiveness + // Others: yield every 8ms as fallback const shouldYield = hasInputPending ? (navigator as any).scheduling.isInputPending({ includeContinuous: true }) - : (performance.now() - lastYield > YIELD_INTERVAL); + : performance.now() - lastYield > YIELD_INTERVAL; if (shouldYield) { - await this.#yieldToMain(); + await yieldToMainThread(); lastYield = performance.now(); } } } /** - * Fetches font with three-tier lookup: in-memory buffer → Cache API → network. - * Cache failures (private browsing, quota limits) are silently ignored. + * Fetches a chunk of fonts concurrently and populates `buffers` with successful results. + * Each promise carries its own key and config so results need no index correlation. + * Aborted fetches are silently skipped; other errors set status to `'error'` and increment retry. */ - async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise { - // Tier 1: in-memory buffer (fastest, no I/O) - const inMemory = this.#buffersByUrl.get(url); - if (inMemory) return inMemory; + async #fetchChunk( + chunk: Array<[string, FontLoadRequestConfig]>, + buffers: Map, + ): Promise { + const results = await Promise.all( + chunk.map(async ([key, config]) => { + this.statuses.set(key, 'loading'); + try { + const buffer = await this.#cache.get(config.url, this.#abortController.signal); + buffers.set(key, buffer); + return { ok: true as const, key }; + } catch (reason) { + return { ok: false as const, key, config, reason }; + } + }), + ); - // Tier 2: Cache API - try { - if (typeof caches !== 'undefined') { - const cache = await caches.open(this.#CACHE_NAME); - const cached = await cache.match(url); - if (cached) return cached.arrayBuffer(); + for (const result of results) { + if (result.ok) continue; + const { key, config, reason } = result; + const isAbort = reason instanceof FontFetchError + && reason.cause instanceof Error + && reason.cause.name === 'AbortError'; + if (isAbort) continue; + if (reason instanceof FontFetchError) { + console.error(`Font fetch failed: ${config.name}`, reason); } - } catch { - // Cache unavailable (private browsing, security restrictions) — fall through to network + this.statuses.set(key, 'error'); + this.#queue.incrementRetry(key); } + } - // Tier 3: network - const response = await fetch(url, { signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - + /** + * Parses a fetched buffer into a {@link FontFace}, registers it with `document.fonts`, + * and updates reactive status. On failure, sets status to `'error'` and increments the retry count. + */ + async #processFont(key: string, config: FontLoadRequestConfig, buffer: ArrayBuffer): Promise { try { - if (typeof caches !== 'undefined') { - const cache = await caches.open(this.#CACHE_NAME); - await cache.put(url, response.clone()); + const font = await loadFont(config, buffer); + this.#loadedFonts.set(key, font); + this.#urlByKey.set(key, config.url); + this.statuses.set(key, 'loaded'); + } catch (e) { + if (e instanceof FontParseError) { + console.error(`Font parse failed: ${config.name}`, e); + this.statuses.set(key, 'error'); + this.#queue.incrementRetry(key); } - } catch { - // Cache write failed (quota, storage pressure) — return font anyway } - - return response.arrayBuffer(); } /** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */ #purgeUnused() { const now = Date.now(); - for (const [key, lastUsed] of this.#usageTracker) { - if (now - lastUsed < this.#TTL) continue; - if (this.#pinnedFonts.has(key)) continue; + // Iterate through all tracked font keys + for (const key of this.#eviction.keys()) { + // Skip fonts that are still within TTL or are pinned + if (!this.#eviction.shouldEvict(key, now)) { + continue; + } + // Remove FontFace from document to free memory const font = this.#loadedFonts.get(key); if (font) document.fonts.delete(font); + // Evict from cache and cleanup URL mapping const url = this.#urlByKey.get(key); if (url) { - this.#buffersByUrl.delete(url); + this.#cache.evict(url); this.#urlByKey.delete(key); } + // Clean up remaining state this.#loadedFonts.delete(key); - this.#usageTracker.delete(key); this.statuses.delete(key); - this.#retryCounts.delete(key); + this.#eviction.remove(key); } } /** Returns current loading status for a font, or undefined if never requested. */ getFontStatus(id: string, weight: number, isVariable = false) { - return this.statuses.get(this.#getFontKey(id, weight, isVariable)); + try { + return this.statuses.get(generateFontKey({ id, weight, isVariable })); + } catch (error) { + console.error(error); + } } /** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */ - pin(id: string, weight: number, isVariable?: boolean): void { - this.#pinnedFonts.add(this.#getFontKey(id, weight, !!isVariable)); + pin(id: string, weight: number, isVariable = false): void { + this.#eviction.pin(generateFontKey({ id, weight, isVariable })); } /** Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires. */ - unpin(id: string, weight: number, isVariable?: boolean): void { - this.#pinnedFonts.delete(this.#getFontKey(id, weight, !!isVariable)); + unpin(id: string, weight: number, isVariable = false): void { + this.#eviction.unpin(generateFontKey({ id, weight, isVariable })); } /** Waits for all fonts to finish loading using document.fonts.ready. */ async ready(): Promise { - if (typeof document === 'undefined') return; + if (typeof document === 'undefined') { + return; + } try { await document.fonts.ready; - } catch { - // document.fonts.ready can reject in some edge cases - // (e.g., document unloaded). Silently resolve. - } + } catch { /* document unloaded */ } } /** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */ destroy() { + // Abort all in-flight network requests this.#abortController.abort(); + // Cancel pending queue processing (idle callback or timeout) if (this.#timeoutId !== null) { if (this.#pendingType === 'idle' && typeof cancelIdleCallback !== 'undefined') { cancelIdleCallback(this.#timeoutId as unknown as number); @@ -365,25 +352,26 @@ export class AppliedFontsManager { this.#pendingType = null; } + // Stop periodic cleanup timer if (this.#intervalId) { clearInterval(this.#intervalId); this.#intervalId = null; } + // Remove all loaded fonts from document if (typeof document !== 'undefined') { for (const font of this.#loadedFonts.values()) { document.fonts.delete(font); } } + // Clear all state and collaborators this.#loadedFonts.clear(); - this.#usageTracker.clear(); - this.#retryCounts.clear(); - this.#buffersByUrl.clear(); this.#urlByKey.clear(); - this.#pinnedFonts.clear(); - this.statuses.clear(); + this.#cache.clear(); + this.#eviction.clear(); this.#queue.clear(); + this.statuses.clear(); } } diff --git a/src/entities/Font/model/store/appliedFontsStore/errors.ts b/src/entities/Font/model/store/appliedFontsStore/errors.ts new file mode 100644 index 0000000..3b617f1 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/errors.ts @@ -0,0 +1,35 @@ +/** + * Thrown by {@link FontBufferCache} when a font file cannot be retrieved from the network or cache. + * + * @property url - The URL that was requested. + * @property cause - The underlying error, if any. + * @property status - HTTP status code. Present on HTTP errors, absent on network failures. + */ +export class FontFetchError extends Error { + readonly name = 'FontFetchError'; + + constructor( + public readonly url: string, + public readonly cause?: unknown, + public readonly status?: number, + ) { + super(status ? `HTTP ${status} fetching font: ${url}` : `Network error fetching font: ${url}`); + } +} + +/** + * Thrown by {@link loadFont} when a font buffer cannot be parsed into a {@link FontFace}. + * + * @property fontName - The display name of the font that failed to parse. + * @property cause - The underlying error from the FontFace API. + */ +export class FontParseError extends Error { + readonly name = 'FontParseError'; + + constructor( + public readonly fontName: string, + public readonly cause?: unknown, + ) { + super(`Failed to parse font: ${fontName}`); + } +} diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.test.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.test.ts new file mode 100644 index 0000000..3ae0884 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.test.ts @@ -0,0 +1,66 @@ +/** @vitest-environment jsdom */ +import { FontFetchError } from '../../errors'; +import { FontBufferCache } from './FontBufferCache'; + +const makeBuffer = () => new ArrayBuffer(8); + +const makeFetcher = (overrides: Partial = {}) => + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(makeBuffer()), + clone: () => ({ ok: true, status: 200, arrayBuffer: () => Promise.resolve(makeBuffer()) }), + ...overrides, + } as Response); + +describe('FontBufferCache', () => { + let cache: FontBufferCache; + let fetcher: ReturnType; + + beforeEach(() => { + fetcher = makeFetcher(); + cache = new FontBufferCache({ fetcher }); + }); + + it('returns buffer from memory on second call without fetching', async () => { + await cache.get('https://example.com/font.woff2'); + await cache.get('https://example.com/font.woff2'); + + expect(fetcher).toHaveBeenCalledOnce(); + }); + + it('throws FontFetchError on HTTP error with correct status', async () => { + const errorFetcher = makeFetcher({ ok: false, status: 404 }); + const errorCache = new FontBufferCache({ fetcher: errorFetcher }); + + const err = await errorCache.get('https://example.com/font.woff2').catch(e => e); + expect(err).toBeInstanceOf(FontFetchError); + expect(err.status).toBe(404); + }); + + it('throws FontFetchError on network failure without status', async () => { + const networkFetcher = vi.fn().mockRejectedValue(new Error('network down')); + const networkCache = new FontBufferCache({ fetcher: networkFetcher }); + + const err = await networkCache.get('https://example.com/font.woff2').catch(e => e); + expect(err).toBeInstanceOf(FontFetchError); + expect(err.status).toBeUndefined(); + }); + + it('evict removes url from memory so next call fetches again', async () => { + await cache.get('https://example.com/font.woff2'); + cache.evict('https://example.com/font.woff2'); + await cache.get('https://example.com/font.woff2'); + + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('clear wipes all memory cache entries', async () => { + await cache.get('https://example.com/a.woff2'); + await cache.get('https://example.com/b.woff2'); + cache.clear(); + await cache.get('https://example.com/a.woff2'); + + expect(fetcher).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.ts new file mode 100644 index 0000000..a2e6ace --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.ts @@ -0,0 +1,97 @@ +import { FontFetchError } from '../../errors'; + +type Fetcher = (url: string, init?: RequestInit) => Promise; + +interface FontBufferCacheOptions { + /** Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation. */ + fetcher?: Fetcher; + /** Cache API cache name. Defaults to `'font-cache-v1'`. */ + cacheName?: string; +} + +/** + * Three-tier font buffer cache: in-memory → Cache API → network. + * + * - **Tier 1 (memory):** Fastest — no I/O. Populated after first successful fetch. + * - **Tier 2 (Cache API):** Persists across page loads. Silently skipped in private browsing. + * - **Tier 3 (network):** Raw fetch. Throws {@link FontFetchError} on failure. + * + * The `fetcher` option is injectable for testing — pass a `vi.fn()` to avoid real network calls. + */ +export class FontBufferCache { + #buffersByUrl = new Map(); + + readonly #fetcher: Fetcher; + readonly #cacheName: string; + + constructor( + { fetcher = globalThis.fetch.bind(globalThis), cacheName = 'font-cache-v1' }: FontBufferCacheOptions = {}, + ) { + this.#fetcher = fetcher; + this.#cacheName = cacheName; + } + + /** + * Retrieves the font buffer for the given URL using the three-tier strategy. + * Stores the result in memory on success. + * + * @throws {@link FontFetchError} if the network request fails or returns a non-OK response. + */ + async get(url: string, signal?: AbortSignal): Promise { + // Tier 1: in-memory (fastest, no I/O) + const inMemory = this.#buffersByUrl.get(url); + if (inMemory) { + return inMemory; + } + + // Tier 2: Cache API + try { + if (typeof caches !== 'undefined') { + const cache = await caches.open(this.#cacheName); + const cached = await cache.match(url); + if (cached) { + const buffer = await cached.arrayBuffer(); + this.#buffersByUrl.set(url, buffer); + return buffer; + } + } + } catch { + // Cache unavailable (private browsing, security restrictions) — fall through to network + } + + // Tier 3: network + let response: Response; + try { + response = await this.#fetcher(url, { signal }); + } catch (cause) { + throw new FontFetchError(url, cause); + } + + if (!response.ok) { + throw new FontFetchError(url, undefined, response.status); + } + + try { + if (typeof caches !== 'undefined') { + const cache = await caches.open(this.#cacheName); + await cache.put(url, response.clone()); + } + } catch { + // Cache write failed (quota, storage pressure) — return font anyway + } + + const buffer = await response.arrayBuffer(); + this.#buffersByUrl.set(url, buffer); + return buffer; + } + + /** Removes a URL from the in-memory cache. Next call to `get()` will re-fetch. */ + evict(url: string): void { + this.#buffersByUrl.delete(url); + } + + /** Clears all in-memory cached buffers. */ + clear(): void { + this.#buffersByUrl.clear(); + } +} diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.test.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.test.ts new file mode 100644 index 0000000..6667c65 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.test.ts @@ -0,0 +1,69 @@ +import { FontEvictionPolicy } from './FontEvictionPolicy'; + +describe('FontEvictionPolicy', () => { + let policy: FontEvictionPolicy; + const TTL = 1000; + const t0 = 100000; + + beforeEach(() => { + policy = new FontEvictionPolicy({ ttl: TTL }); + }); + + it('shouldEvict returns false within TTL', () => { + policy.touch('a@400', t0); + expect(policy.shouldEvict('a@400', t0 + TTL - 1)).toBe(false); + }); + + it('shouldEvict returns true at TTL boundary', () => { + policy.touch('a@400', t0); + expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true); + }); + + it('shouldEvict returns false for pinned key regardless of TTL', () => { + policy.touch('a@400', t0); + policy.pin('a@400'); + expect(policy.shouldEvict('a@400', t0 + TTL * 10)).toBe(false); + }); + + it('shouldEvict returns true again after unpin past TTL', () => { + policy.touch('a@400', t0); + policy.pin('a@400'); + policy.unpin('a@400'); + expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true); + }); + + it('shouldEvict returns false for untracked key', () => { + expect(policy.shouldEvict('never@touched', t0 + TTL * 100)).toBe(false); + }); + + it('keys returns all tracked keys', () => { + policy.touch('a@400', t0); + policy.touch('b@vf', t0); + expect(Array.from(policy.keys())).toEqual(expect.arrayContaining(['a@400', 'b@vf'])); + }); + + it('remove deletes key from tracking so it no longer appears in keys()', () => { + policy.touch('a@400', t0); + policy.touch('b@vf', t0); + policy.remove('a@400'); + expect(Array.from(policy.keys())).not.toContain('a@400'); + expect(Array.from(policy.keys())).toContain('b@vf'); + }); + + it('remove unpins the key so a subsequent touch + TTL would evict it', () => { + policy.touch('a@400', t0); + policy.pin('a@400'); + policy.remove('a@400'); + // re-touch and check it can be evicted again + policy.touch('a@400', t0); + expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true); + }); + + it('clear resets all state', () => { + policy.touch('a@400', t0); + policy.pin('a@400'); + policy.clear(); + expect(Array.from(policy.keys())).toHaveLength(0); + expect(policy.shouldEvict('a@400', t0 + TTL * 10)).toBe(false); + }); +}); diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts new file mode 100644 index 0000000..e99abde --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts @@ -0,0 +1,76 @@ +interface FontEvictionPolicyOptions { + /** TTL in milliseconds. Defaults to 5 minutes. */ + ttl?: number; +} + +/** + * Tracks font usage timestamps and pinned keys to determine when a font should be evicted. + * + * Pure data — no browser APIs. Accepts explicit `now` timestamps so tests + * never need fake timers. + */ +export class FontEvictionPolicy { + #usageTracker = new Map(); + #pinnedFonts = new Set(); + + readonly #TTL: number; + + constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) { + this.#TTL = ttl; + } + + /** + * Records the last-used time for a font key. + * @param key - Font key in `{id}@{weight}` or `{id}@vf` format. + * @param now - Current timestamp in ms. Defaults to `Date.now()`. + */ + touch(key: string, now: number = Date.now()): void { + this.#usageTracker.set(key, now); + } + + /** Pins a font key so it is never evicted regardless of TTL. */ + pin(key: string): void { + this.#pinnedFonts.add(key); + } + + /** Unpins a font key, allowing it to be evicted once its TTL expires. */ + unpin(key: string): void { + this.#pinnedFonts.delete(key); + } + + /** + * Returns `true` if the font should be evicted. + * A font is evicted when its TTL has elapsed and it is not pinned. + * Returns `false` for untracked keys. + * + * @param key - Font key to check. + * @param now - Current timestamp in ms (pass explicitly for deterministic tests). + */ + shouldEvict(key: string, now: number): boolean { + const lastUsed = this.#usageTracker.get(key); + if (lastUsed === undefined) { + return false; + } + if (this.#pinnedFonts.has(key)) { + return false; + } + return now - lastUsed >= this.#TTL; + } + + /** Returns an iterator over all tracked font keys. */ + keys(): IterableIterator { + return this.#usageTracker.keys(); + } + + /** Removes a font key from tracking. Called by the orchestrator after eviction. */ + remove(key: string): void { + this.#usageTracker.delete(key); + this.#pinnedFonts.delete(key); + } + + /** Clears all usage timestamps and pinned keys. */ + clear(): void { + this.#usageTracker.clear(); + this.#pinnedFonts.clear(); + } +} diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.test.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.test.ts new file mode 100644 index 0000000..ab61f5a --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.test.ts @@ -0,0 +1,65 @@ +import type { FontLoadRequestConfig } from '../../../../types'; +import { FontLoadQueue } from './FontLoadQueue'; + +const config = (id: string): FontLoadRequestConfig => ({ + id, + name: id, + url: `https://example.com/${id}.woff2`, + weight: 400, +}); + +describe('FontLoadQueue', () => { + let queue: FontLoadQueue; + + beforeEach(() => { + queue = new FontLoadQueue(); + }); + + it('enqueue returns true for a new key', () => { + expect(queue.enqueue('a@400', config('a'))).toBe(true); + }); + + it('enqueue returns false for an already-queued key', () => { + queue.enqueue('a@400', config('a')); + expect(queue.enqueue('a@400', config('a'))).toBe(false); + }); + + it('has returns true after enqueue, false after flush', () => { + queue.enqueue('a@400', config('a')); + expect(queue.has('a@400')).toBe(true); + queue.flush(); + expect(queue.has('a@400')).toBe(false); + }); + + it('flush returns all entries and atomically clears the queue', () => { + queue.enqueue('a@400', config('a')); + queue.enqueue('b@700', config('b')); + const entries = queue.flush(); + expect(entries).toHaveLength(2); + expect(queue.has('a@400')).toBe(false); + expect(queue.has('b@700')).toBe(false); + }); + + it('isMaxRetriesReached returns false below MAX_RETRIES', () => { + queue.incrementRetry('a@400'); + queue.incrementRetry('a@400'); + expect(queue.isMaxRetriesReached('a@400')).toBe(false); + }); + + it('isMaxRetriesReached returns true at MAX_RETRIES (3)', () => { + queue.incrementRetry('a@400'); + queue.incrementRetry('a@400'); + queue.incrementRetry('a@400'); + expect(queue.isMaxRetriesReached('a@400')).toBe(true); + }); + + it('clear resets queue and retry counts', () => { + queue.enqueue('a@400', config('a')); + queue.incrementRetry('a@400'); + queue.incrementRetry('a@400'); + queue.incrementRetry('a@400'); + queue.clear(); + expect(queue.has('a@400')).toBe(false); + expect(queue.isMaxRetriesReached('a@400')).toBe(false); + }); +}); diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts new file mode 100644 index 0000000..5e6de9f --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts @@ -0,0 +1,57 @@ +import type { FontLoadRequestConfig } from '../../../../types'; + +/** + * Manages the font load queue and per-font retry counts. + * + * Scheduling (when to drain the queue) is handled by the orchestrator — + * this class is purely concerned with what is queued and whether retries are exhausted. + */ +export class FontLoadQueue { + #queue = new Map(); + #retryCounts = new Map(); + + readonly #MAX_RETRIES = 3; + + /** + * Adds a font to the queue. + * @returns `true` if the key was newly enqueued, `false` if it was already present. + */ + enqueue(key: string, config: FontLoadRequestConfig): boolean { + if (this.#queue.has(key)) { + return false; + } + this.#queue.set(key, config); + return true; + } + + /** + * Atomically snapshots and clears the queue. + * @returns All queued entries at the time of the call. + */ + flush(): Array<[string, FontLoadRequestConfig]> { + const entries = Array.from(this.#queue.entries()); + this.#queue.clear(); + return entries; + } + + /** Returns `true` if the key is currently in the queue. */ + has(key: string): boolean { + return this.#queue.has(key); + } + + /** Increments the retry count for a font key. */ + incrementRetry(key: string): void { + this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1); + } + + /** Returns `true` if the font has reached or exceeded the maximum retry limit. */ + isMaxRetriesReached(key: string): boolean { + return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES; + } + + /** Clears all queued fonts and resets all retry counts. */ + clear(): void { + this.#queue.clear(); + this.#retryCounts.clear(); + } +} diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/generateFontKey/generateFontKey.test.ts b/src/entities/Font/model/store/appliedFontsStore/utils/generateFontKey/generateFontKey.test.ts new file mode 100644 index 0000000..3937207 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/generateFontKey/generateFontKey.test.ts @@ -0,0 +1,25 @@ +import { generateFontKey } from './generateFontKey'; + +describe('generateFontKey', () => { + it('should throw an error if font id is not provided', () => { + const config = { weight: 400, isVariable: false }; + // @ts-expect-error + expect(() => generateFontKey(config)).toThrow('Font id is required'); + }); + + it('should generate a font key for a variable font', () => { + const config = { id: 'Roboto', weight: 400, isVariable: true }; + expect(generateFontKey(config)).toBe('roboto@vf'); + }); + + it('should throw an error if font weight is not provided and is not a variable font', () => { + const config = { id: 'Roboto', isVariable: false }; + // @ts-expect-error + expect(() => generateFontKey(config)).toThrow('Font weight is required'); + }); + + it('should generate a font key for a non-variable font', () => { + const config = { id: 'Roboto', weight: 400, isVariable: false }; + expect(generateFontKey(config)).toBe('roboto@400'); + }); +}); diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/generateFontKey/generateFontKey.ts b/src/entities/Font/model/store/appliedFontsStore/utils/generateFontKey/generateFontKey.ts new file mode 100644 index 0000000..1680180 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/generateFontKey/generateFontKey.ts @@ -0,0 +1,22 @@ +import type { FontLoadRequestConfig } from '../../../../types'; + +export type PartialConfig = Pick; + +/** + * Generates a font key for a given font load request configuration. + * @param config - The font load request configuration. + * @returns The generated font key. + */ +export function generateFontKey(config: PartialConfig): string { + if (!config.id) { + throw new Error('Font id is required'); + } + if (config.isVariable) { + return `${config.id.toLowerCase()}@vf`; + } + + if (!config.weight) { + throw new Error('Font weight is required'); + } + return `${config.id.toLowerCase()}@${config.weight}`; +} diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/getEffectiveConcurrency/getEffectiveConcurrency.test.ts b/src/entities/Font/model/store/appliedFontsStore/utils/getEffectiveConcurrency/getEffectiveConcurrency.test.ts new file mode 100644 index 0000000..3bc23b4 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/getEffectiveConcurrency/getEffectiveConcurrency.test.ts @@ -0,0 +1,41 @@ +import { + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { + Concurrency, + getEffectiveConcurrency, +} from './getEffectiveConcurrency'; + +describe('getEffectiveConcurrency', () => { + beforeEach(() => { + const nav = navigator as any; + nav.connection = null; + }); + + it('should return MAX when connection is not available', () => { + const nav = navigator as any; + nav.connection = null; + expect(getEffectiveConcurrency()).toBe(Concurrency.MAX); + }); + + it('should return MIN for slow-2g or 2g connection', () => { + const nav = navigator as any; + nav.connection = { effectiveType: 'slow-2g' }; + expect(getEffectiveConcurrency()).toBe(Concurrency.MIN); + }); + + it('should return AVERAGE for 3g connection', () => { + const nav = navigator as any; + nav.connection = { effectiveType: '3g' }; + expect(getEffectiveConcurrency()).toBe(Concurrency.AVERAGE); + }); + + it('should return MAX for other connection types', () => { + const nav = navigator as any; + nav.connection = { effectiveType: '4g' }; + expect(getEffectiveConcurrency()).toBe(Concurrency.MAX); + }); +}); diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/getEffectiveConcurrency/getEffectiveConcurrency.ts b/src/entities/Font/model/store/appliedFontsStore/utils/getEffectiveConcurrency/getEffectiveConcurrency.ts new file mode 100644 index 0000000..f98bcbb --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/getEffectiveConcurrency/getEffectiveConcurrency.ts @@ -0,0 +1,26 @@ +export enum Concurrency { + MIN = 1, + AVERAGE = 2, + MAX = 4, +} + +/** + * Calculates the amount of fonts for concurrent download based on the user internet connection + */ +export function getEffectiveConcurrency(): number { + const nav = navigator as any; + const connection = nav.connection; + if (!connection) { + return Concurrency.MAX; + } + + switch (connection.effectiveType) { + case 'slow-2g': + case '2g': + return Concurrency.MIN; + case '3g': + return Concurrency.AVERAGE; + default: + return Concurrency.MAX; + } +} diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/index.ts b/src/entities/Font/model/store/appliedFontsStore/utils/index.ts new file mode 100644 index 0000000..dcb4365 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/index.ts @@ -0,0 +1,4 @@ +export { generateFontKey } from './generateFontKey/generateFontKey'; +export { getEffectiveConcurrency } from './getEffectiveConcurrency/getEffectiveConcurrency'; +export { loadFont } from './loadFont/loadFont'; +export { yieldToMainThread } from './yieldToMainThread/yieldToMainThread'; diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts b/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts new file mode 100644 index 0000000..4b7a667 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts @@ -0,0 +1,93 @@ +/** @vitest-environment jsdom */ +import { FontParseError } from '../../errors'; +import { loadFont } from './loadFont'; + +describe('loadFont', () => { + let mockFontInstance: any; + let mockFontFaceSet: { add: ReturnType; delete: ReturnType }; + + beforeEach(() => { + 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, options: FontFaceDescriptors) { + this.name = name; + this.buffer = buffer; + this.options = options; + this.load = vi.fn().mockResolvedValue(this); + mockFontInstance = this; + }, + ); + vi.stubGlobal('FontFace', MockFontFace); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('constructs FontFace with exact weight for static fonts', async () => { + const buffer = new ArrayBuffer(8); + await loadFont({ name: 'Roboto', weight: 400 }, buffer); + + expect(FontFace).toHaveBeenCalledWith('Roboto', buffer, expect.objectContaining({ weight: '400' })); + }); + + it('constructs FontFace with weight range for variable fonts', async () => { + const buffer = new ArrayBuffer(8); + await loadFont({ name: 'Roboto', weight: 400, isVariable: true }, buffer); + + expect(FontFace).toHaveBeenCalledWith('Roboto', buffer, expect.objectContaining({ weight: '100 900' })); + }); + + it('sets style: normal and display: swap on FontFace options', async () => { + await loadFont({ name: 'Lato', weight: 700 }, new ArrayBuffer(8)); + + expect(FontFace).toHaveBeenCalledWith( + 'Lato', + expect.anything(), + expect.objectContaining({ style: 'normal', display: 'swap' }), + ); + }); + + it('passes the buffer as the second argument to FontFace', async () => { + const buffer = new ArrayBuffer(16); + await loadFont({ name: 'Inter', weight: 400 }, buffer); + + expect(FontFace).toHaveBeenCalledWith('Inter', buffer, expect.anything()); + }); + + it('calls font.load() and adds the font to document.fonts', async () => { + const buffer = new ArrayBuffer(8); + const result = await loadFont({ name: 'Inter', weight: 400 }, buffer); + + expect(mockFontInstance.load).toHaveBeenCalledOnce(); + expect(mockFontFaceSet.add).toHaveBeenCalledWith(mockFontInstance); + expect(result).toBe(mockFontInstance); + }); + + it('throws FontParseError when font.load() rejects', async () => { + const loadError = new Error('parse failed'); + const MockFontFace = vi.fn( + function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) { + this.load = vi.fn().mockRejectedValue(loadError); + }, + ); + vi.stubGlobal('FontFace', MockFontFace); + + await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toBeInstanceOf( + FontParseError, + ); + }); + + it('throws FontParseError when document.fonts.add throws', async () => { + const addError = new Error('add failed'); + mockFontFaceSet.add.mockImplementation(() => { + throw addError; + }); + + await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toBeInstanceOf( + FontParseError, + ); + }); +}); diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.ts b/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.ts new file mode 100644 index 0000000..205c18c --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.ts @@ -0,0 +1,27 @@ +import type { FontLoadRequestConfig } from '../../../../types'; +import { FontParseError } from '../../errors'; + +export type PartialConfig = Pick; +/** + * Loads a font from a buffer and adds it to the document's font collection. + * @param config - The font load request configuration. + * @param buffer - The buffer containing the font data. + * @returns A promise that resolves to the loaded `FontFace`. + * @throws {@link FontParseError} When the font buffer cannot be parsed or added to the document font set. + */ +export async function loadFont(config: PartialConfig, buffer: BufferSource): Promise { + try { + const weightRange = config.isVariable ? '100 900' : `${config.weight}`; + const font = new FontFace(config.name, buffer, { + weight: weightRange, + style: 'normal', + display: 'swap', + }); + await font.load(); + document.fonts.add(font); + + return font; + } catch (error) { + throw new FontParseError(config.name, error); + } +} diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/yieldToMainThread/yieldToMainThread.test.ts b/src/entities/Font/model/store/appliedFontsStore/utils/yieldToMainThread/yieldToMainThread.test.ts new file mode 100644 index 0000000..16f9c51 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/yieldToMainThread/yieldToMainThread.test.ts @@ -0,0 +1,17 @@ +import { yieldToMainThread } from './yieldToMainThread'; + +describe('yieldToMainThread', () => { + it('uses scheduler.yield when available', async () => { + const mockYield = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('scheduler', { yield: mockYield }); + + await yieldToMainThread(); + + expect(mockYield).toHaveBeenCalledOnce(); + vi.unstubAllGlobals(); + }); + it('falls back to MessageChannel when scheduler is unavailable', async () => { + // scheduler is not defined in jsdom by default + await expect(yieldToMainThread()).resolves.toBeUndefined(); + }); +}); diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/yieldToMainThread/yieldToMainThread.ts b/src/entities/Font/model/store/appliedFontsStore/utils/yieldToMainThread/yieldToMainThread.ts new file mode 100644 index 0000000..4fa26a3 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/yieldToMainThread/yieldToMainThread.ts @@ -0,0 +1,16 @@ +/** + * Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback. + */ +export async function yieldToMainThread(): Promise { + // @ts-expect-error - scheduler not in TypeScript lib yet + if (typeof scheduler !== 'undefined' && 'yield' in scheduler) { + // @ts-expect-error - scheduler.yield not in TypeScript lib yet + await scheduler.yield(); + } else { + await new Promise(resolve => { + const ch = new MessageChannel(); + ch.port1.onmessage = () => resolve(); + ch.port2.postMessage(null); + }); + } +} diff --git a/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.spec.ts b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.spec.ts new file mode 100644 index 0000000..d944d93 --- /dev/null +++ b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.spec.ts @@ -0,0 +1,644 @@ +import { QueryClient } from '@tanstack/query-core'; +import { flushSync } from 'svelte'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { generateMockFonts } from '../../../lib/mocks/fonts.mock'; +import type { UnifiedFont } from '../../types'; +import { BaseFontStore } from './baseFontStore.svelte'; + +vi.mock('$shared/api/queryClient', () => ({ + queryClient: new QueryClient({ + defaultOptions: { + queries: { + retry: 0, + gcTime: 0, + }, + }, + }), +})); + +import { queryClient } from '$shared/api/queryClient'; + +interface TestParams { + limit?: number; + offset?: number; + q?: string; + providers?: string[]; + categories?: string[]; + subsets?: string[]; +} + +class TestFontStore extends BaseFontStore { + protected getQueryKey(params: TestParams) { + return ['testFonts', params] as const; + } + + protected async fetchFn(params: TestParams): Promise { + return generateMockFonts(params.limit || 10); + } +} + +describe('baseFontStore', () => { + describe('constructor', () => { + afterEach(() => { + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('creates a new store with initial params', () => { + const store = new TestFontStore({ limit: 20, offset: 10 }); + + expect(store.params.limit).toBe(20); + expect(store.params.offset).toBe(10); + store.destroy(); + }); + + it('defaults offset to 0 if not provided', () => { + const store = new TestFontStore({ limit: 10 }); + + expect(store.params.offset).toBe(0); + store.destroy(); + }); + + it('initializes observer with query options', () => { + const store = new TestFontStore({ limit: 10 }); + + expect((store as any).observer).toBeDefined(); + store.destroy(); + }); + }); + + describe('params getter', () => { + let store: TestFontStore; + + beforeEach(() => { + store = new TestFontStore({ limit: 10, offset: 0 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('returns merged internal params', () => { + store.setParams({ limit: 20 }); + flushSync(); + + expect(store.params.limit).toBe(20); + expect(store.params.offset).toBe(0); + }); + + it('defaults offset to 0 when undefined', () => { + const store2 = new TestFontStore({}); + flushSync(); + + expect(store2.params.offset).toBe(0); + store2.destroy(); + }); + }); + + describe('state getters', () => { + let store: TestFontStore; + + beforeEach(() => { + store = new TestFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + describe('fonts', () => { + it('returns fonts after auto-fetch on mount', async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(store.fonts).toHaveLength(10); + }); + + it('returns fonts when data is loaded', async () => { + await store.refetch(); + flushSync(); + + expect(store.fonts).toHaveLength(10); + }); + + it('returns fonts when data is loaded', async () => { + await store.refetch(); + flushSync(); + + expect(store.fonts).toHaveLength(10); + }); + }); + + describe('isLoading', () => { + it('is false after initial fetch completes', async () => { + await store.refetch(); + flushSync(); + + expect(store.isLoading).toBe(false); + }); + + it('is false when error occurs', async () => { + vi.spyOn(store, 'fetchFn' as any).mockRejectedValue(new Error('fail')); + await store.refetch().catch(() => {}); + flushSync(); + + expect(store.isLoading).toBe(false); + }); + }); + + describe('isFetching', () => { + it('is false after fetch completes', async () => { + await store.refetch(); + flushSync(); + + expect(store.isFetching).toBe(false); + }); + + it('is true during refetch', async () => { + await store.refetch(); + flushSync(); + + const refetchPromise = store.refetch(); + flushSync(); + + expect(store.isFetching).toBe(true); + await refetchPromise; + }); + }); + + describe('isError', () => { + it('is false initially', () => { + expect(store.isError).toBe(false); + }); + + it('is true after fetch error', async () => { + vi.spyOn(store, 'fetchFn' as any).mockRejectedValue(new Error('fail')); + await store.refetch().catch(() => {}); + flushSync(); + + expect(store.isError).toBe(true); + }); + + it('is false after successful fetch', async () => { + await store.refetch(); + flushSync(); + + expect(store.isError).toBe(false); + }); + }); + + describe('error', () => { + it('is null initially', () => { + expect(store.error).toBeNull(); + }); + + it('returns error object after fetch error', async () => { + const testError = new Error('test error'); + vi.spyOn(store, 'fetchFn' as any).mockRejectedValue(testError); + await store.refetch().catch(() => {}); + flushSync(); + + expect(store.error).toBe(testError); + }); + + it('is null after successful fetch', async () => { + await store.refetch(); + flushSync(); + + expect(store.error).toBeNull(); + }); + }); + + describe('isEmpty', () => { + it('is true when no fonts loaded and not loading', async () => { + await store.refetch(); + flushSync(); + store.setQueryData(() => []); + flushSync(); + + expect(store.isEmpty).toBe(true); + }); + + it('is false when fonts are present', async () => { + await store.refetch(); + flushSync(); + + expect(store.isEmpty).toBe(false); + }); + + it('is false when loading', () => { + expect(store.isEmpty).toBe(false); + }); + }); + }); + + describe('setParams', () => { + let store: TestFontStore; + + beforeEach(() => { + store = new TestFontStore({ limit: 10, offset: 0 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('merges new params with existing', () => { + store.setParams({ limit: 20 }); + flushSync(); + + expect(store.params.limit).toBe(20); + expect(store.params.offset).toBe(0); + }); + + it('replaces existing param values', () => { + store.setParams({ limit: 30 }); + flushSync(); + + store.setParams({ limit: 40 }); + flushSync(); + + expect(store.params.limit).toBe(40); + }); + + it('triggers observer options update', async () => { + const setOptionsSpy = vi.spyOn((store as any).observer, 'setOptions'); + + store.setParams({ limit: 20 }); + flushSync(); + + expect(setOptionsSpy).toHaveBeenCalled(); + }); + }); + + describe('updateInternalParams', () => { + let store: TestFontStore; + + beforeEach(() => { + store = new TestFontStore({ limit: 10, offset: 20 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('updates internal params without triggering setParams hooks', () => { + (store as any).updateInternalParams({ offset: 0 }); + flushSync(); + + expect(store.params.offset).toBe(0); + expect(store.params.limit).toBe(10); + }); + + it('merges with existing internal params', () => { + (store as any).updateInternalParams({ offset: 0, limit: 30 }); + flushSync(); + + expect(store.params.offset).toBe(0); + expect(store.params.limit).toBe(30); + }); + + it('updates observer options', () => { + const setOptionsSpy = vi.spyOn((store as any).observer, 'setOptions'); + + (store as any).updateInternalParams({ offset: 0 }); + flushSync(); + + expect(setOptionsSpy).toHaveBeenCalled(); + }); + }); + + describe('invalidate', () => { + let store: TestFontStore; + + beforeEach(() => { + store = new TestFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('invalidates query for current params', async () => { + await store.refetch(); + flushSync(); + + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + store.invalidate(); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['testFonts', store.params], + }); + }); + + it('triggers refetch of invalidated query', async () => { + await store.refetch(); + flushSync(); + + const fetchSpy = vi.spyOn(store, 'fetchFn' as any); + store.invalidate(); + await store.refetch(); + flushSync(); + + expect(fetchSpy).toHaveBeenCalled(); + }); + }); + + describe('destroy', () => { + it('calls cleanup function', () => { + const store = new TestFontStore({ limit: 10 }); + const cleanupSpy = vi.spyOn(store, 'cleanup' as any); + + store.destroy(); + + expect(cleanupSpy).toHaveBeenCalled(); + }); + + it('can be called multiple times without error', () => { + const store = new TestFontStore({ limit: 10 }); + + expect(() => { + store.destroy(); + store.destroy(); + }).not.toThrow(); + }); + }); + + describe('refetch', () => { + let store: TestFontStore; + + beforeEach(() => { + store = new TestFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('triggers a refetch', async () => { + const fetchSpy = vi.spyOn(store, 'fetchFn' as any); + await store.refetch(); + flushSync(); + + expect(fetchSpy).toHaveBeenCalled(); + }); + + it('updates observer options before refetching', async () => { + const setOptionsSpy = vi.spyOn((store as any).observer, 'setOptions'); + const refetchSpy = vi.spyOn((store as any).observer, 'refetch'); + + await store.refetch(); + flushSync(); + + expect(setOptionsSpy).toHaveBeenCalledBefore(refetchSpy); + }); + + it('uses current params for refetch', async () => { + store.setParams({ limit: 20 }); + flushSync(); + + await store.refetch(); + flushSync(); + + expect(store.params.limit).toBe(20); + }); + }); + + describe('prefetch', () => { + let store: TestFontStore; + + beforeEach(() => { + store = new TestFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('prefetches data with provided params', async () => { + const prefetchSpy = vi.spyOn(queryClient, 'prefetchQuery'); + + await store.prefetch({ limit: 20, offset: 0 }); + + expect(prefetchSpy).toHaveBeenCalled(); + }); + + it('stores prefetched data in cache', async () => { + queryClient.clear(); + + const store2 = new TestFontStore({ limit: 10 }); + await store2.prefetch({ limit: 5, offset: 0 }); + flushSync(); + + const cached = store2.getCachedData(); + expect(cached).toBeDefined(); + expect(cached?.length).toBeGreaterThanOrEqual(0); + store2.destroy(); + }); + }); + + describe('cancel', () => { + let store: TestFontStore; + + beforeEach(() => { + store = new TestFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('cancels ongoing queries', () => { + const cancelSpy = vi.spyOn(queryClient, 'cancelQueries'); + + store.cancel(); + + expect(cancelSpy).toHaveBeenCalledWith({ + queryKey: ['testFonts', store.params], + }); + }); + }); + + describe('getCachedData', () => { + let store: TestFontStore; + + beforeEach(() => { + store = new TestFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('returns undefined when no data cached', () => { + queryClient.clear(); + + const store2 = new TestFontStore({ limit: 10 }); + expect(store2.getCachedData()).toBeUndefined(); + store2.destroy(); + }); + + it('returns cached data after fetch', async () => { + await store.refetch(); + flushSync(); + + const cached = store.getCachedData(); + expect(cached).toHaveLength(10); + }); + + it('returns data from manual cache update', () => { + store.setQueryData(() => [generateMockFonts(1)[0]]); + flushSync(); + + const cached = store.getCachedData(); + expect(cached).toHaveLength(1); + }); + }); + + describe('setQueryData', () => { + let store: TestFontStore; + + beforeEach(() => { + store = new TestFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('sets data in cache', () => { + store.setQueryData(() => [generateMockFonts(1)[0]]); + flushSync(); + + const cached = store.getCachedData(); + expect(cached).toHaveLength(1); + }); + + it('updates existing cached data', async () => { + await store.refetch(); + flushSync(); + + store.setQueryData(old => [...(old || []), generateMockFonts(1)[0]]); + flushSync(); + + const cached = store.getCachedData(); + expect(cached).toHaveLength(11); + }); + + it('receives previous data in updater function', async () => { + await store.refetch(); + flushSync(); + + const updater = vi.fn((old: UnifiedFont[] | undefined) => old || []); + store.setQueryData(updater); + flushSync(); + + expect(updater).toHaveBeenCalledWith(expect.any(Array)); + }); + }); + + describe('getOptions', () => { + let store: TestFontStore; + + beforeEach(() => { + store = new TestFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('returns query options with query key', () => { + const options = (store as any).getOptions(); + + expect(options.queryKey).toEqual(['testFonts', store.params]); + }); + + it('returns query options with query fn', () => { + const options = (store as any).getOptions(); + + expect(options.queryFn).toBeDefined(); + }); + + it('uses provided params when passed', () => { + const customParams = { limit: 20, offset: 0 }; + const options = (store as any).getOptions(customParams); + + expect(options.queryKey).toEqual(['testFonts', customParams]); + }); + + it('has default staleTime and gcTime', () => { + const options = (store as any).getOptions(); + + expect(options.staleTime).toBe(5 * 60 * 1000); + expect(options.gcTime).toBe(10 * 60 * 1000); + }); + }); + + describe('observer integration', () => { + let store: TestFontStore; + + beforeEach(() => { + store = new TestFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('syncs observer state to Svelte state', async () => { + await store.refetch(); + flushSync(); + + expect(store.fonts).toHaveLength(10); + }); + + it('observer syncs on state changes', async () => { + await store.refetch(); + flushSync(); + + expect((store as any).result.data).toHaveLength(10); + }); + }); + + describe('effect cleanup', () => { + it('cleanup function is set on constructor', () => { + const store = new TestFontStore({ limit: 10 }); + + expect(store.cleanup).toBeDefined(); + expect(typeof store.cleanup).toBe('function'); + + store.destroy(); + }); + }); +}); diff --git a/src/entities/Font/model/store/baseFontStore.svelte.ts b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts similarity index 75% rename from src/entities/Font/model/store/baseFontStore.svelte.ts rename to src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts index 07c9d82..0a612ee 100644 --- a/src/entities/Font/model/store/baseFontStore.svelte.ts +++ b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts @@ -5,7 +5,7 @@ import { type QueryObserverOptions, type QueryObserverResult, } from '@tanstack/query-core'; -import type { UnifiedFont } from '../types'; +import type { UnifiedFont } from '../../types'; /** * Base class for font stores using TanStack Query @@ -23,25 +23,22 @@ export abstract class BaseFontStore> { */ cleanup: () => void; - /** Reactive parameter bindings from external sources */ - #bindings = $state<(() => Partial)[]>([]); /** Internal parameter state */ #internalParams = $state({} as TParams); /** - * Merged params from internal state and all bindings - * Automatically updates when bindings or internal params change + * Merged params from internal state + * Computed synchronously on access */ - params = $derived.by(() => { - let merged = { ...this.#internalParams }; - - // Merge all binding results into params - for (const getter of this.#bindings) { - const bindingResult = getter(); - merged = { ...merged, ...bindingResult }; + get params(): TParams { + // Default offset to 0 if undefined (for pagination methods) + let result = this.#internalParams as TParams; + if (result.offset === undefined) { + result = { ...result, offset: 0 } as TParams; } - return merged as TParams; - }); + + return result; + } /** TanStack Query result state */ protected result = $state>({} as any); @@ -89,9 +86,10 @@ export abstract class BaseFontStore> { * @param params - Query parameters (defaults to current params) */ protected getOptions(params = this.params): QueryObserverOptions { + // Always use current params, not the captured closure params return { queryKey: this.getQueryKey(params), - queryFn: () => this.fetchFn(params), + queryFn: () => this.fetchFn(this.params), staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, }; @@ -117,30 +115,35 @@ export abstract class BaseFontStore> { return this.result.isError; } + /** The error from the last failed fetch, or null if no error. */ + get error(): Error | null { + return this.result.error ?? null; + } + /** Whether no fonts are loaded (not loading and empty array) */ get isEmpty() { return !this.isLoading && this.fonts.length === 0; } - /** - * Add a reactive parameter binding - * @param getter - Function that returns partial params to merge - * @returns Unbind function to remove the binding - */ - addBinding(getter: () => Partial) { - this.#bindings.push(getter); - - return () => { - this.#bindings = this.#bindings.filter(b => b !== getter); - }; - } - /** * Update query parameters * @param newParams - Partial params to merge with existing */ setParams(newParams: Partial) { - this.#internalParams = { ...this.params, ...newParams }; + this.#internalParams = { ...this.#internalParams, ...newParams }; + // Manually update observer options since effects may not run in test contexts + this.observer.setOptions(this.getOptions()); + } + + /** + * Update internal params without triggering setParams hooks + * Used for resetting offset when filters change + * @param newParams - Partial params to merge with existing + */ + protected updateInternalParams(newParams: Partial) { + this.#internalParams = { ...this.#internalParams, ...newParams }; + // Update observer options + this.observer.setOptions(this.getOptions()); } /** @@ -161,6 +164,8 @@ export abstract class BaseFontStore> { * Manually trigger a refetch */ async refetch() { + // Update options before refetching to ensure current params are used + this.observer.setOptions(this.getOptions()); await this.observer.refetch(); } @@ -180,15 +185,6 @@ export abstract class BaseFontStore> { }); } - /** - * Clear cache for current params - */ - clearCache() { - this.qc.removeQueries({ - queryKey: this.getQueryKey(this.params), - }); - } - /** * Get cached data without triggering fetch */ diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index c110ee4..4f0fc38 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -11,10 +11,7 @@ export { createUnifiedFontStore, type UnifiedFontStore, unifiedFontStore, -} from './unifiedFontStore.svelte'; +} from './unifiedFontStore/unifiedFontStore.svelte'; // Applied fonts manager (CSS loading - unchanged) -export { - appliedFontsManager, - type FontConfigRequest, -} from './appliedFontsStore/appliedFontsStore.svelte'; +export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; diff --git a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.spec.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.spec.ts new file mode 100644 index 0000000..aa4eea6 --- /dev/null +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.spec.ts @@ -0,0 +1,474 @@ +import { QueryClient } from '@tanstack/query-core'; +import { tick } from 'svelte'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { + FontNetworkError, + FontResponseError, +} from '../../../lib/errors/errors'; + +vi.mock('$shared/api/queryClient', () => ({ + queryClient: new QueryClient({ + defaultOptions: { + queries: { + retry: 0, + gcTime: 0, + }, + }, + }), +})); + +vi.mock('../../../api', () => ({ + fetchProxyFonts: vi.fn(), +})); + +import { queryClient } from '$shared/api/queryClient'; +import { flushSync } from 'svelte'; +import { fetchProxyFonts } from '../../../api'; +import { + generateMixedCategoryFonts, + generateMockFonts, +} from '../../../lib/mocks/fonts.mock'; +import type { UnifiedFont } from '../../types'; +import { UnifiedFontStore } from './unifiedFontStore.svelte'; + +const mockedFetch = fetchProxyFonts as ReturnType; + +const makeResponse = ( + fonts: UnifiedFont[], + meta: { total?: number; limit?: number; offset?: number } = {}, +) => ({ + fonts, + total: meta.total ?? fonts.length, + limit: meta.limit ?? 10, + offset: meta.offset ?? 0, +}); +describe('unifiedFontStore', () => { + describe('fetchFn — error paths', () => { + let store: UnifiedFontStore; + + beforeEach(() => { + store = new UnifiedFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('sets isError and error getter when fetchProxyFonts throws', async () => { + mockedFetch.mockRejectedValue(new Error('network down')); + await store.refetch().catch((e: unknown) => e); + + expect(store.error).toBeInstanceOf(FontNetworkError); + expect((store.error as FontNetworkError).cause).toBeInstanceOf(Error); + expect(store.isError).toBe(true); + }); + + it('throws FontResponseError when response is falsy', async () => { + mockedFetch.mockResolvedValue(undefined); + + await store.refetch().catch((e: unknown) => e); + + expect(store.error).toBeInstanceOf(FontResponseError); + expect((store.error as FontResponseError).field).toBe('response'); + }); + + it('throws FontResponseError when response.fonts is missing', async () => { + mockedFetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 }); + + await store.refetch().catch((e: unknown) => e); + + expect(store.error).toBeInstanceOf(FontResponseError); + expect((store.error as FontResponseError).field).toBe('response.fonts'); + }); + + it('throws FontResponseError when response.fonts is not an array', async () => { + mockedFetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 }); + + await store.refetch().catch((e: unknown) => e); + + expect(store.error).toBeInstanceOf(FontResponseError); + expect((store.error as FontResponseError).field).toBe('response.fonts'); + expect((store.error as FontResponseError).received).toBe('bad'); + }); + }); + + describe('fetchFn — success path', () => { + let store: UnifiedFontStore; + + beforeEach(() => { + store = new UnifiedFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('populates fonts after a successful fetch', async () => { + const fonts = generateMockFonts(3); + mockedFetch.mockResolvedValue(makeResponse(fonts)); + await store.refetch(); + + expect(store.fonts).toHaveLength(3); + expect(store.fonts[0].id).toBe(fonts[0].id); + }); + + it('stores pagination metadata from response', async () => { + const fonts = generateMockFonts(3); + mockedFetch.mockResolvedValue(makeResponse(fonts, { total: 30, limit: 10, offset: 0 })); + await store.refetch(); + + expect(store.pagination.total).toBe(30); + expect(store.pagination.limit).toBe(10); + expect(store.pagination.offset).toBe(0); + }); + + it('replaces accumulated fonts on offset-0 fetch', async () => { + const first = generateMockFonts(3); + mockedFetch.mockResolvedValue(makeResponse(first)); + await store.refetch(); + flushSync(); + + const second = generateMockFonts(2); + mockedFetch.mockResolvedValue(makeResponse(second)); + await store.refetch(); + flushSync(); + + expect(store.fonts).toHaveLength(2); + expect(store.fonts[0].id).toBe(second[0].id); + }); + + it('appends fonts when fetching at offset > 0', async () => { + const firstPage = generateMockFonts(3); + mockedFetch.mockResolvedValue(makeResponse(firstPage, { total: 6, limit: 3, offset: 0 })); + await store.refetch(); + + const secondPage = generateMockFonts(3).map((f, i) => ({ + ...f, + id: `page2-font-${i + 1}`, + })); + mockedFetch.mockResolvedValue(makeResponse(secondPage, { total: 6, limit: 3, offset: 3 })); + store.setParams({ offset: 3 }); + await store.refetch(); + + expect(store.fonts).toHaveLength(6); + expect(store.fonts.slice(0, 3).map(f => f.id)).toEqual(firstPage.map(f => f.id)); + expect(store.fonts.slice(3).map(f => f.id)).toEqual(secondPage.map(f => f.id)); + }); + }); + + describe('pagination state', () => { + let store: UnifiedFontStore; + + beforeEach(() => { + store = new UnifiedFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('returns default pagination before any fetch', () => { + expect(store.pagination.total).toBe(0); + expect(store.pagination.hasMore).toBe(false); + expect(store.pagination.page).toBe(1); + expect(store.pagination.totalPages).toBe(0); + }); + + it('computes hasMore as true when more pages remain', async () => { + mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 })); + await store.refetch(); + + expect(store.pagination.hasMore).toBe(true); + }); + + it('computes hasMore as false on last page', async () => { + mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 20, limit: 10, offset: 10 })); + store.setParams({ offset: 10 }); + await store.refetch(); + + expect(store.pagination.hasMore).toBe(false); + }); + + it('computes page and totalPages from response metadata', async () => { + mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 })); + store.setParams({ offset: 10 }); + await store.refetch(); + + expect(store.pagination.page).toBe(2); + expect(store.pagination.totalPages).toBe(3); + }); + }); + + describe('pagination navigation', () => { + let store: UnifiedFontStore; + + beforeEach(async () => { + mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 })); + store = new UnifiedFontStore({ limit: 10 }); + await tick(); + await store.refetch(); + await tick(); + flushSync(); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('nextPage() advances offset by limit when hasMore', () => { + store.nextPage(); + flushSync(); + + expect(store.params.offset).toBe(10); + }); + + it('nextPage() does nothing when hasMore is false', async () => { + mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 20, limit: 10, offset: 10 })); + store.setParams({ offset: 10 }); + await store.refetch(); + flushSync(); + + store.nextPage(); + flushSync(); + + expect(store.params.offset).toBe(10); + }); + + it('prevPage() decrements offset by limit when on page > 1', async () => { + mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 })); + store.setParams({ offset: 10 }); + await store.refetch(); + flushSync(); + + store.prevPage(); + flushSync(); + + expect(store.params.offset).toBe(0); + }); + + it('prevPage() does nothing on the first page', () => { + store.prevPage(); + flushSync(); + + expect(store.params.offset).toBe(0); + }); + + it('goToPage() sets the correct offset', () => { + store.goToPage(2); + flushSync(); + + expect(store.params.offset).toBe(10); + }); + + it('goToPage() does nothing for page 0', () => { + store.goToPage(0); + flushSync(); + + expect(store.params.offset).toBe(0); + }); + + it('goToPage() does nothing for page beyond totalPages', () => { + store.goToPage(99); + flushSync(); + + expect(store.params.offset).toBe(0); + }); + + it('setLimit() updates the limit param', () => { + store.setLimit(25); + flushSync(); + + expect(store.params.limit).toBe(25); + }); + }); + + describe('filter setters', () => { + let store: UnifiedFontStore; + + beforeEach(() => { + store = new UnifiedFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('setProviders() updates the providers param', () => { + store.setProviders(['google']); + flushSync(); + + expect(store.params.providers).toEqual(['google']); + }); + + it('setCategories() updates the categories param', () => { + store.setCategories(['serif']); + flushSync(); + + expect(store.params.categories).toEqual(['serif']); + }); + + it('setSubsets() updates the subsets param', () => { + store.setSubsets(['cyrillic']); + flushSync(); + + expect(store.params.subsets).toEqual(['cyrillic']); + }); + + it('setSearch() sets the q param', () => { + store.setSearch('roboto'); + flushSync(); + + expect(store.params.q).toBe('roboto'); + }); + + it('setSearch() with empty string sets q to undefined', () => { + store.setSearch('roboto'); + store.setSearch(''); + flushSync(); + + expect(store.params.q).toBeUndefined(); + }); + + it('setSort() updates the sort param', () => { + store.setSort('popularity'); + flushSync(); + + expect(store.params.sort).toBe('popularity'); + }); + }); + + describe('filter change resets pagination', () => { + let store: UnifiedFontStore; + + beforeEach(() => { + store = new UnifiedFontStore({ limit: 10 }); + flushSync(); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('resets offset to 0 when a filter changes', () => { + store.setParams({ offset: 20 }); + flushSync(); + + store.setParams({ q: 'roboto' }); + flushSync(); + + expect(store.params.offset).toBe(0); + }); + + it('clears accumulated fonts when a filter changes', async () => { + const fonts = generateMockFonts(3); + mockedFetch.mockResolvedValue(makeResponse(fonts)); + await store.refetch(); + flushSync(); + expect(store.fonts).toHaveLength(3); + + store.setParams({ q: 'roboto' }); + flushSync(); + + expect(store.fonts).toHaveLength(0); + }); + }); + + describe('category getters', () => { + let store: UnifiedFontStore; + + beforeEach(() => { + store = new UnifiedFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('sansSerifFonts returns only sans-serif fonts', async () => { + const fonts = generateMixedCategoryFonts(2); + mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length })); + await store.refetch(); + + expect(store.fonts).toHaveLength(10); + expect(store.sansSerifFonts).toHaveLength(2); + expect(store.sansSerifFonts.every(f => f.category === 'sans-serif')).toBe(true); + }); + + it('serifFonts returns only serif fonts', async () => { + const fonts = generateMixedCategoryFonts(2); + mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length })); + await store.refetch(); + + expect(store.serifFonts).toHaveLength(2); + expect(store.serifFonts.every(f => f.category === 'serif')).toBe(true); + }); + + it('displayFonts returns only display fonts', async () => { + const fonts = generateMixedCategoryFonts(2); + mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length })); + await store.refetch(); + + expect(store.displayFonts).toHaveLength(2); + expect(store.displayFonts.every(f => f.category === 'display')).toBe(true); + }); + + it('handwritingFonts returns only handwriting fonts', async () => { + const fonts = generateMixedCategoryFonts(2); + mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length })); + await store.refetch(); + + expect(store.handwritingFonts).toHaveLength(2); + expect(store.handwritingFonts.every(f => f.category === 'handwriting')).toBe(true); + }); + + it('monospaceFonts returns only monospace fonts', async () => { + const fonts = generateMixedCategoryFonts(2); + mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length })); + await store.refetch(); + + expect(store.monospaceFonts).toHaveLength(2); + expect(store.monospaceFonts.every(f => f.category === 'monospace')).toBe(true); + }); + }); + + describe('destroy', () => { + it('calls parent destroy and filterCleanup', () => { + const store = new UnifiedFontStore({ limit: 10 }); + const parentDestroySpy = vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(store)), 'destroy'); + + store.destroy(); + + expect(parentDestroySpy).toHaveBeenCalled(); + }); + + it('can be called multiple times without throwing', () => { + const store = new UnifiedFontStore({ limit: 10 }); + store.destroy(); + + expect(() => { + store.destroy(); + }).not.toThrow(); + }); + }); +}); diff --git a/src/entities/Font/model/store/unifiedFontStore.svelte.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts similarity index 75% rename from src/entities/Font/model/store/unifiedFontStore.svelte.ts rename to src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts index 33c7f07..85ee71b 100644 --- a/src/entities/Font/model/store/unifiedFontStore.svelte.ts +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts @@ -13,10 +13,14 @@ */ import type { QueryObserverOptions } from '@tanstack/query-core'; -import type { ProxyFontsParams } from '../../api'; -import { fetchProxyFonts } from '../../api'; -import type { UnifiedFont } from '../types'; -import { BaseFontStore } from './baseFontStore.svelte'; +import type { ProxyFontsParams } from '../../../api'; +import { fetchProxyFonts } from '../../../api'; +import { + FontNetworkError, + FontResponseError, +} from '../../../lib/errors/errors'; +import type { UnifiedFont } from '../../types'; +import { BaseFontStore } from '../baseFontStore/baseFontStore.svelte'; /** * Unified font store wrapping TanStack Query with Svelte 5 runes @@ -24,7 +28,7 @@ import { BaseFontStore } from './baseFontStore.svelte'; * Extends BaseFontStore to provide: * - Reactive state management * - TanStack Query integration for caching - * - Dynamic parameter binding for filters + * - Filter change tracking with pagination reset * - Pagination support * * @example @@ -93,7 +97,7 @@ export class UnifiedFontStore extends BaseFontStore { /** * Track previous filter params to detect changes and reset pagination */ - #previousFilterParams = $state(''); + #previousFilterParams = $state(null); /** * Cleanup function for the filter tracking effect @@ -130,11 +134,12 @@ export class UnifiedFontStore extends BaseFontStore { // Effect: Sync state from Query result (Handles Cache Hits) $effect(() => { const data = this.result.data; - const offset = this.params.offset || 0; + const offset = this.params.offset ?? 0; // When we have data and we are at the start (offset 0), // we must ensure accumulatedFonts matches the fresh (or cached) data. // This fixes the issue where cache hits skip fetchFn side-effects. + // Only sync at offset 0 to avoid clearing fonts during cache hits at other offsets. if (offset === 0 && data && data.length > 0) { this.#accumulatedFonts = data; } @@ -188,37 +193,35 @@ export class UnifiedFontStore extends BaseFontStore { * Returns the full response including pagination metadata */ protected async fetchFn(params: ProxyFontsParams): Promise { - const response = await fetchProxyFonts(params); + let response: Awaited>; + try { + response = await fetchProxyFonts(params); + } catch (cause) { + throw new FontNetworkError(cause); + } - // Validate response structure if (!response) { - console.error('[UnifiedFontStore] fetchProxyFonts returned undefined', { params }); - throw new Error('Proxy API returned undefined response'); + throw new FontResponseError('response', response); } - if (!response.fonts) { - console.error('[UnifiedFontStore] response.fonts is undefined', { response }); - throw new Error('Proxy API response missing fonts array'); + throw new FontResponseError('response.fonts', response.fonts); } - if (!Array.isArray(response.fonts)) { - console.error('[UnifiedFontStore] response.fonts is not an array', { - fonts: response.fonts, - }); - throw new Error('Proxy API fonts is not an array'); + throw new FontResponseError('response.fonts', response.fonts); } - // Store pagination metadata separately for derived values this.#paginationMetadata = { total: response.total ?? 0, limit: response.limit ?? this.params.limit ?? 50, offset: response.offset ?? this.params.offset ?? 0, }; - // Accumulate fonts for infinite scroll - // Note: For offset === 0, we rely on the $effect above to handle the reset/init - // This prevents race conditions and double-setting. - if (params.offset !== 0) { + const offset = params.offset ?? 0; + if (offset === 0) { + // Replace accumulated fonts on offset-0 fetch + this.#accumulatedFonts = response.fonts; + } else { + // Append fonts when fetching at offset > 0 this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts]; } @@ -260,6 +263,57 @@ export class UnifiedFontStore extends BaseFontStore { return !this.isLoading && this.fonts.length === 0; } + /** + * Check if filter params changed and reset if needed + * Manually called in setParams to handle test contexts where $effect doesn't run + */ + #checkAndResetFilters(newParams: Partial) { + // Only check filter-related params (not offset/limit/page) + const isFilterChange = 'q' in newParams || 'providers' in newParams || 'categories' in newParams + || 'subsets' in newParams; + + if (!isFilterChange) { + return; + } + + const filterParams = JSON.stringify({ + providers: this.params.providers, + categories: this.params.categories, + subsets: this.params.subsets, + q: this.params.q, + }); + + if (filterParams !== this.#previousFilterParams) { + // Reset offset if filter params changed + if (this.params.offset !== 0) { + // Update internal params directly to avoid recursion + this.updateInternalParams({ offset: 0 }); + } + + // Clear fonts if there are accumulated fonts + // (to avoid clearing on initial setup when no fonts exist) + if (this.#accumulatedFonts.length > 0) { + this.#accumulatedFonts = []; + // Clear the result to prevent effect from using stale cached data + this.result.data = undefined; + } + + this.invalidate(); + this.#previousFilterParams = filterParams; + } + } + + /** + * Override setParams to check for filter changes + * @param newParams - Partial params to merge with existing + */ + setParams(newParams: Partial) { + // First update params normally + super.setParams(newParams); + // Then check if filters changed (for test contexts) + this.#checkAndResetFilters(newParams); + } + /** * Set providers filter */ diff --git a/src/entities/Font/model/types/index.ts b/src/entities/Font/model/types/index.ts index 5dfa1c9..b216577 100644 --- a/src/entities/Font/model/types/index.ts +++ b/src/entities/Font/model/types/index.ts @@ -56,3 +56,5 @@ export type { FontCollectionSort, FontCollectionState, } from './store'; + +export * from './store/appliedFonts'; diff --git a/src/entities/Font/model/types/store/appliedFonts.ts b/src/entities/Font/model/types/store/appliedFonts.ts new file mode 100644 index 0000000..a2bae5b --- /dev/null +++ b/src/entities/Font/model/types/store/appliedFonts.ts @@ -0,0 +1,30 @@ +/** + * Configuration for a font load request. + */ +export interface FontLoadRequestConfig { + /** + * Unique identifier for the font (e.g., "lato", "roboto"). + */ + id: string; + /** + * Actual font family name recognized by the browser (e.g., "Lato", "Roboto"). + */ + name: string; + /** + * URL pointing to the font file (typically .ttf or .woff2). + */ + url: string; + /** + * Numeric weight (100-900). Variable fonts load once per ID regardless of weight. + */ + weight: number; + /** + * Variable fonts load once per ID; static fonts load per weight. + */ + isVariable?: boolean; +} + +/** + * Loading state of a font. + */ +export type FontLoadStatus = 'loading' | 'loaded' | 'error'; diff --git a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte index ba32dbd..9277a38 100644 --- a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte +++ b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte @@ -15,7 +15,7 @@ import type { import { fade } from 'svelte/transition'; import { getFontUrl } from '../../lib'; import { - type FontConfigRequest, + type FontLoadRequestConfig, type UnifiedFont, appliedFontsManager, unifiedFontStore, @@ -54,7 +54,7 @@ const isLoading = $derived( ); function handleInternalVisibleChange(visibleItems: UnifiedFont[]) { - const configs: FontConfigRequest[] = []; + const configs: FontLoadRequestConfig[] = []; visibleItems.forEach(item => { const url = getFontUrl(item, weight); diff --git a/src/shared/lib/utils/buildQueryString/buildQueryString.ts b/src/shared/lib/utils/buildQueryString/buildQueryString.ts index 7c4817c..79cb5b3 100644 --- a/src/shared/lib/utils/buildQueryString/buildQueryString.ts +++ b/src/shared/lib/utils/buildQueryString/buildQueryString.ts @@ -7,7 +7,7 @@ * @example * ```ts * buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] }) - * // Returns: "?category=serif&subsets=latin%2Clatin-ext" + * // Returns: "?category=serif&subsets=latin&subsets=latin-ext" * * buildQueryString({ limit: 50, page: 1 }) * // Returns: "?limit=50&page=1" @@ -16,7 +16,7 @@ * // Returns: "" * * buildQueryString({ search: 'hello world', active: true }) - * // Returns: "?search=hello%20world&active=true" + * // Returns: "?search=hello+world&active=true" * ``` */ @@ -35,7 +35,7 @@ export type QueryParams = Record; * * Handles: * - Primitive values (string, number, boolean) - converted to strings - * - Arrays - comma-separated values + * - Arrays - multiple parameters with same key (e.g., ?key=1&key=2&key=3) * - null/undefined - omitted from output * - Special characters - URL encoded * @@ -51,14 +51,12 @@ export function buildQueryString(params: QueryParams): string { continue; } - // Handle arrays (comma-separated values) + // Handle arrays - append each item as separate parameter with same key if (Array.isArray(value)) { - const joined = value - .filter(item => item !== undefined && item !== null) - .map(String) - .join(','); - if (joined) { - searchParams.append(key, joined); + for (const item of value) { + if (item !== undefined && item !== null) { + searchParams.append(key, String(item)); + } } } else { // Handle primitives diff --git a/vitest.config.ts b/vitest.config.ts index b14ba67..3e26d12 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -49,7 +49,7 @@ export default defineConfig({ }, }, setupFiles: ['./vitest.setup.unit.ts'], - globals: false, + globals: true, }, resolve: {