From 0fdded79d7bbf2511a06d6009f6602d4a786ee2a Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 11:02:17 +0300 Subject: [PATCH 01/32] test: change globals to true to use vitest tools without importing them --- vitest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: { From e553cf1f10c777a5b13c6b2c7665f6e591a135be Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 11:03:48 +0300 Subject: [PATCH 02/32] feat(appliedFontsStore): create separate getEffectiveConcurrency function with proper tests --- .../appliedFontsStore.svelte.ts | 3 +- .../getEffectiveConcurrency.test.ts | 41 +++++++++++++++++++ .../getEffectiveConcurrency.ts | 26 ++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/entities/Font/model/store/appliedFontsStore/utils/getEffectiveConcurrency/getEffectiveConcurrency.test.ts create mode 100644 src/entities/Font/model/store/appliedFontsStore/utils/getEffectiveConcurrency/getEffectiveConcurrency.ts diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 17e58cf..1758d36 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -1,4 +1,5 @@ import { SvelteMap } from 'svelte/reactivity'; +import { getEffectiveConcurrency } from './utils/getEffectiveConcurrency/getEffectiveConcurrency'; /** Loading state of a font. Failed loads may be retried up to MAX_RETRIES. */ export type FontStatus = 'loading' | 'loaded' | 'error'; @@ -200,7 +201,7 @@ export class AppliedFontsManager { } // Phase 1: Concurrent fetching (I/O bound, non-blocking) - const concurrency = this.#getEffectiveConcurrency(); + const concurrency = getEffectiveConcurrency(); const buffers = new Map(); for (let i = 0; i < entries.length; i += concurrency) { 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; + } +} From 5249d88df71792d931193c5284af5fea5381902b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 11:05:29 +0300 Subject: [PATCH 03/32] feat(appliedFontsStore): create separate yieldToMainThread function with proper tests --- .../appliedFontsStore.svelte.ts | 37 +++---------------- .../store/appliedFontsStore/utils/index.ts | 2 + .../yieldToMainThread.test.ts | 17 +++++++++ .../yieldToMainThread/yieldToMainThread.ts | 16 ++++++++ 4 files changed, 40 insertions(+), 32 deletions(-) create mode 100644 src/entities/Font/model/store/appliedFontsStore/utils/index.ts create mode 100644 src/entities/Font/model/store/appliedFontsStore/utils/yieldToMainThread/yieldToMainThread.test.ts create mode 100644 src/entities/Font/model/store/appliedFontsStore/utils/yieldToMainThread/yieldToMainThread.ts diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 1758d36..903122e 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -1,5 +1,8 @@ import { SvelteMap } from 'svelte/reactivity'; -import { getEffectiveConcurrency } from './utils/getEffectiveConcurrency/getEffectiveConcurrency'; +import { + getEffectiveConcurrency, + yieldToMainThread, +} from './utils'; /** Loading state of a font. Failed loads may be retried up to MAX_RETRIES. */ export type FontStatus = 'loading' | 'loaded' | 'error'; @@ -143,37 +146,7 @@ export class AppliedFontsManager { } } - /** 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(); - } 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; - } - } /** Returns true if data-saver mode is enabled (defers non-critical weights). */ #shouldDeferNonCritical(): boolean { @@ -261,7 +234,7 @@ export class AppliedFontsManager { : (performance.now() - lastYield > YIELD_INTERVAL); if (shouldYield) { - await this.#yieldToMain(); + await yieldToMainThread(); lastYield = performance.now(); } } 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..c16a74b --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/index.ts @@ -0,0 +1,2 @@ +export { getEffectiveConcurrency } from './getEffectiveConcurrency/getEffectiveConcurrency'; +export { yieldToMainThread } from './yieldToMainThread/yieldToMainThread'; 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); + }); + } +} From b602b5022b2f4ece104524e4eb552f008c2fd144 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 12:25:38 +0300 Subject: [PATCH 04/32] chore(appliedFontsStore): move the FontLoadRequestConfig type and other types from appliedFontsStore into types directory --- src/entities/Font/model/index.ts | 3 +- .../appliedFontsStore.svelte.ts | 37 ++++--------------- src/entities/Font/model/store/index.ts | 5 +-- src/entities/Font/model/types/index.ts | 2 + .../Font/model/types/store/appliedFonts.ts | 30 +++++++++++++++ .../ui/FontVirtualList/FontVirtualList.svelte | 4 +- 6 files changed, 44 insertions(+), 37 deletions(-) create mode 100644 src/entities/Font/model/types/store/appliedFonts.ts 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/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 903122e..ef2bacb 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -1,36 +1,13 @@ import { SvelteMap } from 'svelte/reactivity'; +import type { + FontLoadRequestConfig, + FontLoadStatus, +} from '../../types'; import { getEffectiveConcurrency, yieldToMainThread, } from './utils'; -/** 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; -} - /** * Manages web font loading with caching, adaptive concurrency, and automatic cleanup. * @@ -62,7 +39,7 @@ export class AppliedFontsManager { #usageTracker = new Map(); // Fonts queued for loading by `touch()`, processed by `#processQueue()` - #queue = new Map(); + #queue = new Map(); // Handle for scheduled queue processing (requestIdleCallback or setTimeout) #timeoutId: ReturnType | null = null; @@ -94,7 +71,7 @@ export class AppliedFontsManager { readonly #CACHE_NAME = 'font-cache-v1'; // Versioned for future invalidation // Reactive status map for Svelte components to track font states - statuses = new SvelteMap(); + statuses = new SvelteMap(); // Starts periodic cleanup timer (browser-only). constructor() { @@ -114,7 +91,7 @@ export class AppliedFontsManager { * 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[]) { + touch(configs: FontLoadRequestConfig[]) { if (this.#abortController.signal.aborted) return; const now = Date.now(); diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index c110ee4..0e2e684 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -14,7 +14,4 @@ export { } from './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/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); From 05e4c082edb94de4560efacf59d260993a0748b4 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 12:26:23 +0300 Subject: [PATCH 05/32] feat(appliedFontsStore): move font loading logic into loadFont function and cover it with tests --- .../appliedFontsStore.svelte.ts | 20 ++-- .../store/appliedFontsStore/utils/index.ts | 1 + .../utils/loadFont/loadFont.test.ts | 92 +++++++++++++++++++ .../utils/loadFont/loadFont.ts | 26 ++++++ 4 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts create mode 100644 src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.ts diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index ef2bacb..62cda8d 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -1,10 +1,11 @@ import { SvelteMap } from 'svelte/reactivity'; -import type { - FontLoadRequestConfig, - FontLoadStatus, +import { + type FontLoadRequestConfig, + type FontLoadStatus, } from '../../types'; import { getEffectiveConcurrency, + loadFont, yieldToMainThread, } from './utils'; @@ -184,17 +185,12 @@ export class AppliedFontsManager { for (const [key, config] of entries) { const buffer = buffers.get(key); - if (!buffer) continue; + 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); + const font = await loadFont(config, buffer); this.#loadedFonts.set(key, font); this.#buffersByUrl.set(config.url, buffer); this.#urlByKey.set(key, config.url); diff --git a/src/entities/Font/model/store/appliedFontsStore/utils/index.ts b/src/entities/Font/model/store/appliedFontsStore/utils/index.ts index c16a74b..bf35e55 100644 --- a/src/entities/Font/model/store/appliedFontsStore/utils/index.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/index.ts @@ -1,2 +1,3 @@ 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..22dfcbd --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts @@ -0,0 +1,92 @@ +/** @vitest-environment jsdom */ +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('propagates and logs error when font.load() rejects', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + 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.toThrow('parse failed'); + expect(consoleSpy).toHaveBeenCalledWith(loadError); + }); + + it('propagates and logs error when document.fonts.add throws', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const addError = new Error('add failed'); + mockFontFaceSet.add.mockImplementation(() => { + throw addError; + }); + + await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toThrow('add failed'); + expect(consoleSpy).toHaveBeenCalledWith(addError); + }); +}); 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..71839f6 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.ts @@ -0,0 +1,26 @@ +import type { FontLoadRequestConfig } from '../../../../types'; + +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`. + */ +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) { + console.error(error); + throw error; + } +} From a711e4e12a72c0c7bb2cdfc640fdc68d2bd8c330 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 12:50:50 +0300 Subject: [PATCH 06/32] chore(appliedFontsStore): move generateFontKey into separate function and cover it with tests --- .../appliedFontsStore.svelte.ts | 83 +++++++++++-------- .../generateFontKey/generateFontKey.test.ts | 25 ++++++ .../utils/generateFontKey/generateFontKey.ts | 22 +++++ .../store/appliedFontsStore/utils/index.ts | 1 + 4 files changed, 98 insertions(+), 33 deletions(-) create mode 100644 src/entities/Font/model/store/appliedFontsStore/utils/generateFontKey/generateFontKey.test.ts create mode 100644 src/entities/Font/model/store/appliedFontsStore/utils/generateFontKey/generateFontKey.ts diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 62cda8d..98dc0f8 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -4,6 +4,7 @@ import { type FontLoadStatus, } from '../../types'; import { + generateFontKey, getEffectiveConcurrency, loadFont, yieldToMainThread, @@ -81,11 +82,6 @@ export class AppliedFontsManager { } } - // 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. * @@ -93,34 +89,40 @@ export class AppliedFontsManager { * Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms). */ touch(configs: FontLoadRequestConfig[]) { - 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; + if (this.#abortController.signal.aborted) { + return; } - 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'; + try { + const now = Date.now(); + let hasNewItems = false; + + for (const config of configs) { + const key = generateFontKey(config); + 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; } + + 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'; + } + } + } catch (error) { + console.error(error); } } @@ -274,17 +276,32 @@ export class AppliedFontsManager { /** 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 { + const key = generateFontKey({ id, weight, isVariable }); + return this.statuses.get(key); + } 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)); + try { + const key = generateFontKey({ id, weight, isVariable: !!isVariable }); + this.#pinnedFonts.add(key); + } catch (error) { + console.error(error); + } } /** 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)); + try { + const key = generateFontKey({ id, weight, isVariable: !!isVariable }); + this.#pinnedFonts.delete(key); + } catch (error) { + console.error(error); + } } /** Waits for all fonts to finish loading using document.fonts.ready. */ 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/index.ts b/src/entities/Font/model/store/appliedFontsStore/utils/index.ts index bf35e55..dcb4365 100644 --- a/src/entities/Font/model/store/appliedFontsStore/utils/index.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/index.ts @@ -1,3 +1,4 @@ +export { generateFontKey } from './generateFontKey/generateFontKey'; export { getEffectiveConcurrency } from './getEffectiveConcurrency/getEffectiveConcurrency'; export { loadFont } from './loadFont/loadFont'; export { yieldToMainThread } from './yieldToMainThread/yieldToMainThread'; From d6eb02bb281b1e32a65567397ac3c9568a4687fe Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 14:44:06 +0300 Subject: [PATCH 07/32] feat: add FontFetchError and FontParseError typed errors --- .../model/store/appliedFontsStore/errors.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/entities/Font/model/store/appliedFontsStore/errors.ts 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..f7201bb --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/errors.ts @@ -0,0 +1,35 @@ +/** + * Thrown 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}`); + } +} From 64b97794a66c1beb91833ead57ddf5edfebe251d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 15:01:36 +0300 Subject: [PATCH 08/32] feat: extract FontLoadQueue with retry tracking --- .../fontLoadQueue/FontLoadQueue.test.ts | 65 +++++++++++++++++++ .../fontLoadQueue/FontLoadQueue.ts | 55 ++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/entities/Font/model/store/appliedFontsStore/fontLoadQueue/FontLoadQueue.test.ts create mode 100644 src/entities/Font/model/store/appliedFontsStore/fontLoadQueue/FontLoadQueue.ts diff --git a/src/entities/Font/model/store/appliedFontsStore/fontLoadQueue/FontLoadQueue.test.ts b/src/entities/Font/model/store/appliedFontsStore/fontLoadQueue/FontLoadQueue.test.ts new file mode 100644 index 0000000..f833fcf --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/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/fontLoadQueue/FontLoadQueue.ts b/src/entities/Font/model/store/appliedFontsStore/fontLoadQueue/FontLoadQueue.ts new file mode 100644 index 0000000..1cc6685 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/fontLoadQueue/FontLoadQueue.ts @@ -0,0 +1,55 @@ +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(); + } +} From 128f3413994fc4f6e8b9c9a6423d18d366da00b0 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 15:06:01 +0300 Subject: [PATCH 09/32] feat: extract FontEvictionPolicy with TTL and pin/unpin --- .../FontEvictionPolicy.test.ts | 52 +++++++++++++++ .../fontEvictionPolicy/FontEvictionPolicy.ts | 66 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts create mode 100644 src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts diff --git a/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts b/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts new file mode 100644 index 0000000..400a39f --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts @@ -0,0 +1,52 @@ +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('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/fontEvictionPolicy/FontEvictionPolicy.ts b/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts new file mode 100644 index 0000000..d692684 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts @@ -0,0 +1,66 @@ +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(); + } + + /** Clears all usage timestamps and pinned keys. */ + clear(): void { + this.#usageTracker.clear(); + this.#pinnedFonts.clear(); + } +} From 46ce0f7aab001f5e66057997f0ce9e4971bba933 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 15:24:14 +0300 Subject: [PATCH 10/32] feat: extract FontBufferCache with injectable fetcher --- .../model/store/appliedFontsStore/errors.ts | 2 +- .../fontBufferCache/FontBufferCache.test.ts | 66 +++++++++++++ .../fontBufferCache/FontBufferCache.ts | 95 +++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts create mode 100644 src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.ts diff --git a/src/entities/Font/model/store/appliedFontsStore/errors.ts b/src/entities/Font/model/store/appliedFontsStore/errors.ts index f7201bb..3b617f1 100644 --- a/src/entities/Font/model/store/appliedFontsStore/errors.ts +++ b/src/entities/Font/model/store/appliedFontsStore/errors.ts @@ -1,5 +1,5 @@ /** - * Thrown when a font file cannot be retrieved from the network or cache. + * 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. diff --git a/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts b/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts new file mode 100644 index 0000000..311347d --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/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/fontBufferCache/FontBufferCache.ts b/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.ts new file mode 100644 index 0000000..1e49873 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.ts @@ -0,0 +1,95 @@ +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(); + } +} From 37e0c29788d1eb383cf4d1944b186bb80bd5abc6 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 15:42:08 +0300 Subject: [PATCH 11/32] refactor: loadFont throws FontParseError instead of re-throwing raw error --- .../utils/loadFont/loadFont.test.ts | 17 +++++++++-------- .../utils/loadFont/loadFont.ts | 5 +++-- 2 files changed, 12 insertions(+), 10 deletions(-) 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 index 22dfcbd..4b7a667 100644 --- a/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.test.ts @@ -1,4 +1,5 @@ /** @vitest-environment jsdom */ +import { FontParseError } from '../../errors'; import { loadFont } from './loadFont'; describe('loadFont', () => { @@ -65,8 +66,7 @@ describe('loadFont', () => { expect(result).toBe(mockFontInstance); }); - it('propagates and logs error when font.load() rejects', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + 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) { @@ -75,18 +75,19 @@ describe('loadFont', () => { ); vi.stubGlobal('FontFace', MockFontFace); - await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toThrow('parse failed'); - expect(consoleSpy).toHaveBeenCalledWith(loadError); + await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toBeInstanceOf( + FontParseError, + ); }); - it('propagates and logs error when document.fonts.add throws', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + 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.toThrow('add failed'); - expect(consoleSpy).toHaveBeenCalledWith(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 index 71839f6..205c18c 100644 --- a/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/loadFont/loadFont.ts @@ -1,4 +1,5 @@ import type { FontLoadRequestConfig } from '../../../../types'; +import { FontParseError } from '../../errors'; export type PartialConfig = Pick; /** @@ -6,6 +7,7 @@ export type PartialConfig = Pick { try { @@ -20,7 +22,6 @@ export async function loadFont(config: PartialConfig, buffer: BufferSource): Pro return font; } catch (error) { - console.error(error); - throw error; + throw new FontParseError(config.name, error); } } From bc4ab58644f50c07d29e39db62703d97a42e46e2 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 16:08:15 +0300 Subject: [PATCH 12/32] fix(buildQueryString): change the way the searchParams built --- .../utils/buildQueryString/buildQueryString.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) 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 From d21de1bf78c807c0b81212b4a3aa5cc7c38b580e Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 16:09:10 +0300 Subject: [PATCH 13/32] chore(appliedFontsStore): use created collaborators classes --- .../appliedFontStore.test.ts | 79 ++---- .../appliedFontsStore.svelte.ts | 230 +++++++++--------- 2 files changed, 130 insertions(+), 179 deletions(-) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts index e0eec49..01dad3a 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts @@ -8,37 +8,40 @@ import { vi, } from 'vitest'; import { AppliedFontsManager } from './appliedFontsStore.svelte'; +import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy'; + +class FakeBufferCache { + async get(_url: string): Promise { + return new ArrayBuffer(8); + } + evict(_url: string): void {} + clear(): void {} +} describe('AppliedFontsManager', () => { let manager: AppliedFontsManager; let mockFontFaceSet: any; - let mockFetch: any; - let failUrls: Set; + let fakeEviction: FontEvictionPolicy; beforeEach(() => { vi.useFakeTimers(); - failUrls = new Set(); + fakeEviction = new FontEvictionPolicy({ ttl: 60000 }); 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, @@ -49,25 +52,7 @@ describe('AppliedFontsManager', () => { randomUUID: () => '11111111-1111-1111-1111-111111111111' as any, }); - // 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: fakeEviction }); }); afterEach(() => { @@ -84,29 +69,12 @@ describe('AppliedFontsManager', () => { manager.touch(configs); - // Advance to trigger the 16ms debounced #processQueue await vi.advanceTimersByTimeAsync(50); expect(manager.getFontStatus('lato-400', 400)).toBe('loaded'); expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2); }); - it('should handle font loading errors gracefully', async () => { - // Suppress expected console error for clean test logs - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const failUrl = 'https://example.com/fail.ttf'; - failUrls.add(failUrl); - - const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 }; - - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); - - expect(manager.getFontStatus('broken', 400)).toBe('error'); - spy.mockRestore(); - }); - it('should purge fonts after TTL expires', async () => { const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 }; @@ -114,9 +82,7 @@ describe('AppliedFontsManager', () => { await vi.advanceTimersByTimeAsync(50); expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded'); - // 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); + await vi.advanceTimersByTimeAsync(61000); expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined(); expect(mockFontFaceSet.delete).toHaveBeenCalled(); @@ -128,14 +94,11 @@ describe('AppliedFontsManager', () => { manager.touch([config]); await vi.advanceTimersByTimeAsync(50); - // Advance 4 minutes - await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + await vi.advanceTimersByTimeAsync(40000); - // Refresh touch manager.touch([config]); - // Advance another 2 minutes (Total 6 since start) - await vi.advanceTimersByTimeAsync(2 * 60 * 1000); + await vi.advanceTimersByTimeAsync(20000); expect(manager.getFontStatus('active', 400)).toBe('loaded'); }); @@ -143,22 +106,16 @@ describe('AppliedFontsManager', () => { 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 }; - // First load — populates in-memory buffer manager.touch([config]); await vi.advanceTimersByTimeAsync(50); expect(manager.getFontStatus('cached', 400)).toBe('loaded'); - expect(mockFetch).toHaveBeenCalledTimes(1); - // Simulate eviction by deleting the status entry directly manager.statuses.delete('cached@400'); - // Second load — should hit in-memory buffer, not network manager.touch([config]); await vi.advanceTimersByTimeAsync(50); expect(manager.getFontStatus('cached', 400)).toBe('loaded'); - // fetch should still only have been called once (buffer was reused) - expect(mockFetch).toHaveBeenCalledTimes(1); }); it('should NOT purge a pinned font after TTL expires', async () => { @@ -170,8 +127,7 @@ describe('AppliedFontsManager', () => { manager.pin('pinned', 400); - // Advance past TTL + purge interval - await vi.advanceTimersByTimeAsync(6 * 60 * 1000); + await vi.advanceTimersByTimeAsync(61000); expect(manager.getFontStatus('pinned', 400)).toBe('loaded'); expect(mockFontFaceSet.delete).not.toHaveBeenCalled(); @@ -187,8 +143,7 @@ describe('AppliedFontsManager', () => { manager.pin('unpinned', 400); manager.unpin('unpinned', 400); - // Advance past TTL + purge interval - await vi.advanceTimersByTimeAsync(6 * 60 * 1000); + await vi.advanceTimersByTimeAsync(61000); expect(manager.getFontStatus('unpinned', 400)).toBeUndefined(); expect(mockFontFaceSet.delete).toHaveBeenCalled(); diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 98dc0f8..34fe102 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -3,6 +3,13 @@ import { type FontLoadRequestConfig, type FontLoadStatus, } from '../../types'; +import { + FontFetchError, + FontParseError, +} from './errors'; +import { FontBufferCache } from './fontBufferCache/FontBufferCache'; +import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy'; +import { FontLoadQueue } from './fontLoadQueue/FontLoadQueue'; import { generateFontKey, getEffectiveConcurrency, @@ -10,6 +17,12 @@ import { yieldToMainThread, } from './utils'; +interface AppliedFontsManagerDeps { + cache?: FontBufferCache; + eviction?: FontEvictionPolicy; + queue?: FontLoadQueue; +} + /** * Manages web font loading with caching, adaptive concurrency, and automatic cleanup. * @@ -34,14 +47,16 @@ import { * **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; @@ -55,28 +70,20 @@ 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(); // 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); } @@ -92,24 +99,41 @@ export class AppliedFontsManager { if (this.#abortController.signal.aborted) { return; } - try { const now = Date.now(); let hasNewItems = false; for (const config of configs) { const key = generateFontKey(config); - this.#usageTracker.set(key, now); + + // Update last-used timestamp for LRU eviction policy + this.#eviction.touch(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); + // 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; } + // Schedule queue processing if we have new items and no existing timer if (hasNewItems && !this.#timeoutId) { + // Prefer requestIdleCallback for better performance (waits for browser idle) if (typeof requestIdleCallback !== 'undefined') { this.#timeoutId = requestIdleCallback( () => this.#processQueue(), @@ -117,6 +141,7 @@ export class AppliedFontsManager { ) as unknown as ReturnType; this.#pendingType = 'idle'; } else { + // Fallback to setTimeout with ~60fps timing this.#timeoutId = setTimeout(() => this.#processQueue(), 16); this.#pendingType = 'timeout'; } @@ -126,12 +151,9 @@ export class AppliedFontsManager { } } - /** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */ - /** 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; } /** @@ -142,71 +164,86 @@ 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) + // 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) { + // Process in chunks based on concurrency limit 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, - ); + // Fetch buffer via cache (checks memory → Cache API → network) + const buffer = await this.#cache.get(config.url, this.#abortController.signal); buffers.set(key, buffer); }), ); + // Handle fetch errors - set status and increment retry count 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); + const reason = (results[j] as PromiseRejectedResult).reason; + if (reason instanceof FontFetchError) { + console.error(`Font fetch failed: ${config.name}`, reason); + } this.statuses.set(key, 'error'); - this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1); + this.#queue.incrementRetry(key); } } } - // 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); + // Skip fonts that failed to fetch in phase 1 if (!buffer) { continue; } try { + // Parse buffer into FontFace and register with document const font = await loadFont(config, buffer); 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); + if (e instanceof FontParseError) { + console.error(`Font parse failed: ${config.name}`, e); + this.statuses.set(key, 'error'); + this.#queue.incrementRetry(key); + } } + // 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 yieldToMainThread(); @@ -215,110 +252,68 @@ export class AppliedFontsManager { } } - /** - * Fetches font with three-tier lookup: in-memory buffer → Cache API → network. - * Cache failures (private browsing, quota limits) are silently ignored. - */ - 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; - - // 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(); - } - } catch { - // Cache unavailable (private browsing, security restrictions) — fall through to network - } - - // Tier 3: network - const response = await fetch(url, { signal }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - try { - if (typeof caches !== 'undefined') { - const cache = await caches.open(this.#CACHE_NAME); - await cache.put(url, response.clone()); - } - } 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); } } /** Returns current loading status for a font, or undefined if never requested. */ getFontStatus(id: string, weight: number, isVariable = false) { try { - const key = generateFontKey({ id, weight, isVariable }); - return this.statuses.get(key); + 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 { - try { - const key = generateFontKey({ id, weight, isVariable: !!isVariable }); - this.#pinnedFonts.add(key); - } catch (error) { - console.error(error); - } + 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 { - try { - const key = generateFontKey({ id, weight, isVariable: !!isVariable }); - this.#pinnedFonts.delete(key); - } catch (error) { - console.error(error); - } + 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); @@ -329,25 +324,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(); } } From f88729cc779e154ce335adeee741e09ceb6fb75b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 4 Apr 2026 09:40:21 +0300 Subject: [PATCH 14/32] fix: guard AbortError from retry counting; eviction policy removes stale keys --- .../appliedFontsStore.svelte.ts | 6 ++++++ .../FontEvictionPolicy.test.ts | 17 +++++++++++++++++ .../fontEvictionPolicy/FontEvictionPolicy.ts | 6 ++++++ 3 files changed, 29 insertions(+) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 34fe102..8b86f43 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -202,6 +202,11 @@ export class AppliedFontsManager { if (results[j].status === 'rejected') { const [key, config] = chunk[j]; const reason = (results[j] as PromiseRejectedResult).reason; + // Aborted fetches are not retriable failures — skip silently + 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); } @@ -276,6 +281,7 @@ export class AppliedFontsManager { // Clean up remaining state this.#loadedFonts.delete(key); this.statuses.delete(key); + this.#eviction.remove(key); } } diff --git a/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts b/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts index 400a39f..6667c65 100644 --- a/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts @@ -42,6 +42,23 @@ describe('FontEvictionPolicy', () => { 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'); diff --git a/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts b/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts index d692684..1a3a1b5 100644 --- a/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts +++ b/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts @@ -58,6 +58,12 @@ export class FontEvictionPolicy { 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(); From 20110168f261a49a2ee00ad72bdece24b8eb629c Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 4 Apr 2026 09:52:45 +0300 Subject: [PATCH 15/32] refactor: extract #processFont and #scheduleProcessing from touch and #processQueue --- .../appliedFontsStore.svelte.ts | 64 +++++++++++-------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 8b86f43..33becd1 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -131,26 +131,31 @@ export class AppliedFontsManager { hasNewItems = true; } - // Schedule queue processing if we have new items and no existing timer if (hasNewItems && !this.#timeoutId) { - // Prefer requestIdleCallback for better performance (waits for browser idle) - if (typeof requestIdleCallback !== 'undefined') { - this.#timeoutId = requestIdleCallback( - () => this.#processQueue(), - { timeout: 150 }, - ) as unknown as ReturnType; - this.#pendingType = 'idle'; - } else { - // Fallback to setTimeout with ~60fps timing - this.#timeoutId = setTimeout(() => this.#processQueue(), 16); - this.#pendingType = 'timeout'; - } + this.#scheduleProcessing(); } } catch (error) { console.error(error); } } + /** + * 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 { + this.#timeoutId = setTimeout(() => this.#processQueue(), 16); + this.#pendingType = 'timeout'; + } + } + /** Returns true if data-saver mode is enabled (defers non-critical weights). */ #shouldDeferNonCritical(): boolean { return (navigator as any).connection?.saveData === true; @@ -229,19 +234,7 @@ export class AppliedFontsManager { continue; } - try { - // Parse buffer into FontFace and register with document - 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); - } - } + await this.#processFont(key, config, buffer); // Yield to main thread if needed (prevents UI blocking) // Chromium: use isInputPending() for optimal responsiveness @@ -257,6 +250,25 @@ export class AppliedFontsManager { } } + /** + * 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 { + 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); + } + } + } + /** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */ #purgeUnused() { const now = Date.now(); From 5a065ae5a128fe95b637c9987ffc47c2ba0cbf9a Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 4 Apr 2026 09:58:41 +0300 Subject: [PATCH 16/32] refactor: extract #fetchChunk, replace Promise.allSettled with self-describing results --- .../appliedFontsStore.svelte.ts | 66 +++++++++++-------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 33becd1..789f4a1 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -191,34 +191,7 @@ export class AppliedFontsManager { // ==================== 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) { - // Process in chunks based on concurrency limit - const chunk = entries.slice(i, i + concurrency); - const results = await Promise.allSettled( - chunk.map(async ([key, config]) => { - this.statuses.set(key, 'loading'); - // Fetch buffer via cache (checks memory → Cache API → network) - const buffer = await this.#cache.get(config.url, this.#abortController.signal); - buffers.set(key, buffer); - }), - ); - - // Handle fetch errors - set status and increment retry count - for (let j = 0; j < results.length; j++) { - if (results[j].status === 'rejected') { - const [key, config] = chunk[j]; - const reason = (results[j] as PromiseRejectedResult).reason; - // Aborted fetches are not retriable failures — skip silently - 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); - } - this.statuses.set(key, 'error'); - this.#queue.incrementRetry(key); - } - } + await this.#fetchChunk(entries.slice(i, i + concurrency), buffers); } // ==================== PHASE 2: Sequential Parsing ==================== @@ -250,6 +223,43 @@ export class AppliedFontsManager { } } + /** + * 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 #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 }; + } + }), + ); + + 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); + } + this.statuses.set(key, 'error'); + this.#queue.incrementRetry(key); + } + } + /** * 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. From d4cf6764b42aadb801de8628a6b94cbc4ab3755d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 4 Apr 2026 10:38:20 +0300 Subject: [PATCH 17/32] test(appliedFontsStore): rewrite tests with describe grouping and full coverage --- .../appliedFontStore.test.ts | 354 +++++++++++++----- 1 file changed, 259 insertions(+), 95 deletions(-) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts index 01dad3a..bba32a5 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts @@ -1,15 +1,10 @@ /** @vitest-environment jsdom */ -import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; import { AppliedFontsManager } from './appliedFontsStore.svelte'; +import { FontFetchError } from './errors'; import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy'; +// ── Fake collaborators ──────────────────────────────────────────────────────── + class FakeBufferCache { async get(_url: string): Promise { return new ArrayBuffer(8); @@ -18,29 +13,36 @@ class FakeBufferCache { clear(): void {} } +/** Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. */ +class FailingBufferCache { + async get(url: string): Promise { + throw new FontFetchError(url, new Error('network error'), 500); + } + evict(_url: string): void {} + clear(): void {} +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({ + id, + name: id, + url: `https://example.com/${id}.woff2`, + weight: 400, + ...overrides, +}); + +// ── Suite ───────────────────────────────────────────────────────────────────── + describe('AppliedFontsManager', () => { let manager: AppliedFontsManager; - let mockFontFaceSet: any; - let fakeEviction: FontEvictionPolicy; + let eviction: FontEvictionPolicy; + let mockFontFaceSet: { add: ReturnType; delete: ReturnType }; beforeEach(() => { vi.useFakeTimers(); - fakeEviction = new FontEvictionPolicy({ ttl: 60000 }); - - mockFontFaceSet = { - add: vi.fn(), - delete: vi.fn(), - }; - - const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) { - this.name = name; - this.bufferOrUrl = bufferOrUrl; - this.load = vi.fn().mockImplementation(() => { - return Promise.resolve(this); - }); - }); - - vi.stubGlobal('FontFace', MockFontFace); + eviction = new FontEvictionPolicy({ ttl: 60000 }); + mockFontFaceSet = { add: vi.fn(), delete: vi.fn() }; Object.defineProperty(document, 'fonts', { value: mockFontFaceSet, @@ -48,11 +50,14 @@ describe('AppliedFontsManager', () => { writable: true, }); - vi.stubGlobal('crypto', { - randomUUID: () => '11111111-1111-1111-1111-111111111111' as any, + const MockFontFace = vi.fn(function(this: any, name: string, buffer: BufferSource) { + this.name = name; + this.buffer = buffer; + this.load = vi.fn().mockResolvedValue(this); }); + vi.stubGlobal('FontFace', MockFontFace); - manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction: fakeEviction }); + manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction }); }); afterEach(() => { @@ -61,108 +66,267 @@ describe('AppliedFontsManager', () => { vi.unstubAllGlobals(); }); - it('should batch multiple font requests into a single process', async () => { - const configs = [ - { id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 }, - { id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 }, - ]; + // ── touch() ─────────────────────────────────────────────────────────────── - manager.touch(configs); + describe('touch()', () => { + it('queues and loads a new font', async () => { + manager.touch([makeConfig('roboto')]); + await vi.advanceTimersByTimeAsync(50); - await vi.advanceTimersByTimeAsync(50); + expect(manager.getFontStatus('roboto', 400)).toBe('loaded'); + }); - expect(manager.getFontStatus('lato-400', 400)).toBe('loaded'); - expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2); + it('batches multiple fonts into a single queue flush', async () => { + manager.touch([makeConfig('lato'), makeConfig('inter')]); + await vi.advanceTimersByTimeAsync(50); + + expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2); + }); + + it('skips fonts that are already loaded', async () => { + manager.touch([makeConfig('lato')]); + await vi.advanceTimersByTimeAsync(50); + + manager.touch([makeConfig('lato')]); + await vi.advanceTimersByTimeAsync(50); + + expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1); + }); + + it('skips fonts that are currently loading', async () => { + manager.touch([makeConfig('lato')]); + // simulate loading state before queue drains + manager.statuses.set('lato@400', 'loading'); + manager.touch([makeConfig('lato')]); + await vi.advanceTimersByTimeAsync(50); + + expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1); + }); + + it('skips fonts that have exhausted retries', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction }); + + // exhaust all 3 retries + for (let i = 0; i < 3; i++) { + failManager.statuses.delete('broken@400'); + failManager.touch([makeConfig('broken')]); + await vi.advanceTimersByTimeAsync(50); + } + + failManager.touch([makeConfig('broken')]); + await vi.advanceTimersByTimeAsync(50); + + expect(failManager.getFontStatus('broken', 400)).toBe('error'); + expect(mockFontFaceSet.add).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('does nothing after manager is destroyed', async () => { + manager.destroy(); + manager.touch([makeConfig('roboto')]); + await vi.advanceTimersByTimeAsync(50); + + expect(manager.statuses.size).toBe(0); + }); }); - it('should purge fonts after TTL expires', async () => { - const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 }; + // ── queue processing ────────────────────────────────────────────────────── - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); - expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded'); + describe('queue processing', () => { + it('filters non-critical weights in data-saver mode', async () => { + (navigator as any).connection = { saveData: true }; - await vi.advanceTimersByTimeAsync(61000); + manager.touch([ + makeConfig('light', { weight: 300 }), + makeConfig('regular', { weight: 400 }), + makeConfig('bold', { weight: 700 }), + ]); + await vi.advanceTimersByTimeAsync(50); - expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined(); - expect(mockFontFaceSet.delete).toHaveBeenCalled(); + expect(manager.getFontStatus('light', 300)).toBeUndefined(); + expect(manager.getFontStatus('regular', 400)).toBe('loaded'); + expect(manager.getFontStatus('bold', 700)).toBe('loaded'); + + delete (navigator as any).connection; + }); + + it('loads variable fonts in data-saver mode regardless of weight', async () => { + (navigator as any).connection = { saveData: true }; + + manager.touch([makeConfig('vf', { weight: 300, isVariable: true })]); + await vi.advanceTimersByTimeAsync(50); + + expect(manager.getFontStatus('vf', 300, true)).toBe('loaded'); + + delete (navigator as any).connection; + }); }); - it('should NOT purge fonts that are still being "touched"', async () => { - const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 }; + // ── Phase 1: fetch ──────────────────────────────────────────────────────── - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); + describe('Phase 1 — fetch', () => { + it('sets status to error on fetch failure', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction }); - await vi.advanceTimersByTimeAsync(40000); + failManager.touch([makeConfig('broken')]); + await vi.advanceTimersByTimeAsync(50); - manager.touch([config]); + expect(failManager.getFontStatus('broken', 400)).toBe('error'); + consoleSpy.mockRestore(); + }); - await vi.advanceTimersByTimeAsync(20000); + it('logs a console error on fetch failure', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction }); - expect(manager.getFontStatus('active', 400)).toBe('loaded'); + failManager.touch([makeConfig('broken')]); + await vi.advanceTimersByTimeAsync(50); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('does not set error status or log for aborted fetches', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const abortingCache = { + async get(url: string): Promise { + throw new FontFetchError(url, Object.assign(new Error('Aborted'), { name: 'AbortError' })); + }, + evict() {}, + clear() {}, + }; + const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction }); + + abortManager.touch([makeConfig('aborted')]); + await vi.advanceTimersByTimeAsync(50); + + // status is left as 'loading' (not 'error') — abort is not a retriable failure + expect(abortManager.getFontStatus('aborted', 400)).not.toBe('error'); + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); }); - it('should serve buffer from memory without calling fetch again', async () => { - const config = { id: 'cached', name: 'Cached', url: 'https://example.com/cached.ttf', weight: 400 }; + // ── Phase 2: parse ──────────────────────────────────────────────────────── - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); - expect(manager.getFontStatus('cached', 400)).toBe('loaded'); + describe('Phase 2 — parse', () => { + it('sets status to error on parse failure', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const FailingFontFace = vi.fn(function(this: any) { + this.load = vi.fn().mockRejectedValue(new Error('parse failed')); + }); + vi.stubGlobal('FontFace', FailingFontFace); - manager.statuses.delete('cached@400'); + manager.touch([makeConfig('broken')]); + await vi.advanceTimersByTimeAsync(50); - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); + expect(manager.getFontStatus('broken', 400)).toBe('error'); + consoleSpy.mockRestore(); + }); - expect(manager.getFontStatus('cached', 400)).toBe('loaded'); + it('logs a console error on parse failure', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const FailingFontFace = vi.fn(function(this: any) { + this.load = vi.fn().mockRejectedValue(new Error('parse failed')); + }); + vi.stubGlobal('FontFace', FailingFontFace); + + manager.touch([makeConfig('broken')]); + await vi.advanceTimersByTimeAsync(50); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); }); - it('should NOT purge a pinned font after TTL expires', async () => { - const config = { id: 'pinned', name: 'Pinned', url: 'https://example.com/pinned.ttf', weight: 400 }; + // ── #purgeUnused ────────────────────────────────────────────────────────── - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); - expect(manager.getFontStatus('pinned', 400)).toBe('loaded'); + describe('#purgeUnused', () => { + it('evicts fonts after TTL expires', async () => { + manager.touch([makeConfig('ephemeral')]); + await vi.advanceTimersByTimeAsync(50); - manager.pin('pinned', 400); + await vi.advanceTimersByTimeAsync(61000); - await vi.advanceTimersByTimeAsync(61000); + expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined(); + expect(mockFontFaceSet.delete).toHaveBeenCalled(); + }); - expect(manager.getFontStatus('pinned', 400)).toBe('loaded'); - expect(mockFontFaceSet.delete).not.toHaveBeenCalled(); + it('removes the evicted key from the eviction policy', async () => { + manager.touch([makeConfig('ephemeral')]); + await vi.advanceTimersByTimeAsync(50); + + await vi.advanceTimersByTimeAsync(61000); + + expect(Array.from(eviction.keys())).not.toContain('ephemeral@400'); + }); + + it('refreshes TTL when font is re-touched before expiry', async () => { + const config = makeConfig('active'); + manager.touch([config]); + await vi.advanceTimersByTimeAsync(50); + + await vi.advanceTimersByTimeAsync(40000); + manager.touch([config]); // refresh at t≈40s + + await vi.advanceTimersByTimeAsync(25000); // purge at t≈60s sees only ~20s elapsed → not evicted + + expect(manager.getFontStatus('active', 400)).toBe('loaded'); + }); + + it('does not evict pinned fonts', async () => { + manager.touch([makeConfig('pinned')]); + await vi.advanceTimersByTimeAsync(50); + + manager.pin('pinned', 400); + await vi.advanceTimersByTimeAsync(61000); + + expect(manager.getFontStatus('pinned', 400)).toBe('loaded'); + expect(mockFontFaceSet.delete).not.toHaveBeenCalled(); + }); + + it('evicts font after it is unpinned and TTL expires', async () => { + manager.touch([makeConfig('toggled')]); + await vi.advanceTimersByTimeAsync(50); + + manager.pin('toggled', 400); + manager.unpin('toggled', 400); + await vi.advanceTimersByTimeAsync(61000); + + expect(manager.getFontStatus('toggled', 400)).toBeUndefined(); + expect(mockFontFaceSet.delete).toHaveBeenCalled(); + }); }); - it('should evict a font after it is unpinned and TTL expires', async () => { - const config = { id: 'unpinned', name: 'Unpinned', url: 'https://example.com/unpinned.ttf', weight: 400 }; + // ── destroy() ───────────────────────────────────────────────────────────── - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); - expect(manager.getFontStatus('unpinned', 400)).toBe('loaded'); + describe('destroy()', () => { + it('clears all statuses', async () => { + manager.touch([makeConfig('roboto')]); + await vi.advanceTimersByTimeAsync(50); - manager.pin('unpinned', 400); - manager.unpin('unpinned', 400); + manager.destroy(); - await vi.advanceTimersByTimeAsync(61000); + expect(manager.statuses.size).toBe(0); + }); - expect(manager.getFontStatus('unpinned', 400)).toBeUndefined(); - expect(mockFontFaceSet.delete).toHaveBeenCalled(); - }); + it('removes all loaded fonts from document.fonts', async () => { + manager.touch([makeConfig('roboto'), makeConfig('inter')]); + await vi.advanceTimersByTimeAsync(50); - it('should clear pinned set on destroy without errors', async () => { - const config = { - id: 'destroy-pin', - name: 'DestroyPin', - url: 'https://example.com/destroypin.ttf', - weight: 400, - }; + manager.destroy(); - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); + expect(mockFontFaceSet.delete).toHaveBeenCalledTimes(2); + }); - manager.pin('destroy-pin', 400); - manager.destroy(); + it('prevents further loading after destroy', async () => { + manager.destroy(); + manager.touch([makeConfig('roboto')]); + await vi.advanceTimersByTimeAsync(50); - expect(manager.statuses.size).toBe(0); + expect(manager.statuses.size).toBe(0); + }); }); }); From e88cca92892b0e1a27416293d8ad8c737e28da83 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 4 Apr 2026 16:43:54 +0300 Subject: [PATCH 18/32] test(FontBufferCache): change mock fetcher type --- .../appliedFontsStore/fontBufferCache/FontBufferCache.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts b/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts index 311347d..8008971 100644 --- a/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts @@ -15,7 +15,7 @@ const makeFetcher = (overrides: Partial = {}) => describe('FontBufferCache', () => { let cache: FontBufferCache; - let fetcher: ReturnType; + let fetcher: ReturnType; beforeEach(() => { fetcher = makeFetcher(); From c6dabafd93d6e7b80cd7049bf330f9af0cd2398e Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 5 Apr 2026 08:24:11 +0300 Subject: [PATCH 19/32] chore(appliedFontsStore): move FontBufferCache, FontEvicionPolicy and FontLoadQueue to appliedFontsStore/utils --- .../store/appliedFontsStore/appliedFontStore.test.ts | 2 +- .../store/appliedFontsStore/appliedFontsStore.svelte.ts | 6 +++--- .../{ => utils}/fontBufferCache/FontBufferCache.test.ts | 2 +- .../{ => utils}/fontBufferCache/FontBufferCache.ts | 6 ++++-- .../fontEvictionPolicy/FontEvictionPolicy.test.ts | 0 .../{ => utils}/fontEvictionPolicy/FontEvictionPolicy.ts | 8 ++++++-- .../{ => utils}/fontLoadQueue/FontLoadQueue.test.ts | 2 +- .../{ => utils}/fontLoadQueue/FontLoadQueue.ts | 6 ++++-- 8 files changed, 20 insertions(+), 12 deletions(-) rename src/entities/Font/model/store/appliedFontsStore/{ => utils}/fontBufferCache/FontBufferCache.test.ts (98%) rename src/entities/Font/model/store/appliedFontsStore/{ => utils}/fontBufferCache/FontBufferCache.ts (96%) rename src/entities/Font/model/store/appliedFontsStore/{ => utils}/fontEvictionPolicy/FontEvictionPolicy.test.ts (100%) rename src/entities/Font/model/store/appliedFontsStore/{ => utils}/fontEvictionPolicy/FontEvictionPolicy.ts (93%) rename src/entities/Font/model/store/appliedFontsStore/{ => utils}/fontLoadQueue/FontLoadQueue.test.ts (97%) rename src/entities/Font/model/store/appliedFontsStore/{ => utils}/fontLoadQueue/FontLoadQueue.ts (92%) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts index bba32a5..bac5059 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts @@ -1,7 +1,7 @@ /** @vitest-environment jsdom */ import { AppliedFontsManager } from './appliedFontsStore.svelte'; import { FontFetchError } from './errors'; -import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy'; +import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy'; // ── Fake collaborators ──────────────────────────────────────────────────────── diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 789f4a1..f0ff3cd 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -7,15 +7,15 @@ import { FontFetchError, FontParseError, } from './errors'; -import { FontBufferCache } from './fontBufferCache/FontBufferCache'; -import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy'; -import { FontLoadQueue } from './fontLoadQueue/FontLoadQueue'; 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'; interface AppliedFontsManagerDeps { cache?: FontBufferCache; diff --git a/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.test.ts similarity index 98% rename from src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts rename to src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.test.ts index 8008971..3ae0884 100644 --- a/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.test.ts @@ -1,5 +1,5 @@ /** @vitest-environment jsdom */ -import { FontFetchError } from '../errors'; +import { FontFetchError } from '../../errors'; import { FontBufferCache } from './FontBufferCache'; const makeBuffer = () => new ArrayBuffer(8); diff --git a/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.ts similarity index 96% rename from src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.ts rename to src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.ts index 1e49873..a2e6ace 100644 --- a/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontBufferCache/FontBufferCache.ts @@ -1,4 +1,4 @@ -import { FontFetchError } from '../errors'; +import { FontFetchError } from '../../errors'; type Fetcher = (url: string, init?: RequestInit) => Promise; @@ -40,7 +40,9 @@ export class FontBufferCache { 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; + if (inMemory) { + return inMemory; + } // Tier 2: Cache API try { diff --git a/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.test.ts similarity index 100% rename from src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.test.ts rename to src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.test.ts diff --git a/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts similarity index 93% rename from src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts rename to src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts index 1a3a1b5..e99abde 100644 --- a/src/entities/Font/model/store/appliedFontsStore/fontEvictionPolicy/FontEvictionPolicy.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontEvictionPolicy/FontEvictionPolicy.ts @@ -48,8 +48,12 @@ export class FontEvictionPolicy { */ shouldEvict(key: string, now: number): boolean { const lastUsed = this.#usageTracker.get(key); - if (lastUsed === undefined) return false; - if (this.#pinnedFonts.has(key)) return false; + if (lastUsed === undefined) { + return false; + } + if (this.#pinnedFonts.has(key)) { + return false; + } return now - lastUsed >= this.#TTL; } diff --git a/src/entities/Font/model/store/appliedFontsStore/fontLoadQueue/FontLoadQueue.test.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.test.ts similarity index 97% rename from src/entities/Font/model/store/appliedFontsStore/fontLoadQueue/FontLoadQueue.test.ts rename to src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.test.ts index f833fcf..ab61f5a 100644 --- a/src/entities/Font/model/store/appliedFontsStore/fontLoadQueue/FontLoadQueue.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.test.ts @@ -1,4 +1,4 @@ -import type { FontLoadRequestConfig } from '../../../types'; +import type { FontLoadRequestConfig } from '../../../../types'; import { FontLoadQueue } from './FontLoadQueue'; const config = (id: string): FontLoadRequestConfig => ({ diff --git a/src/entities/Font/model/store/appliedFontsStore/fontLoadQueue/FontLoadQueue.ts b/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts similarity index 92% rename from src/entities/Font/model/store/appliedFontsStore/fontLoadQueue/FontLoadQueue.ts rename to src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts index 1cc6685..5e6de9f 100644 --- a/src/entities/Font/model/store/appliedFontsStore/fontLoadQueue/FontLoadQueue.ts +++ b/src/entities/Font/model/store/appliedFontsStore/utils/fontLoadQueue/FontLoadQueue.ts @@ -1,4 +1,4 @@ -import type { FontLoadRequestConfig } from '../../../types'; +import type { FontLoadRequestConfig } from '../../../../types'; /** * Manages the font load queue and per-font retry counts. @@ -17,7 +17,9 @@ export class FontLoadQueue { * @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; + if (this.#queue.has(key)) { + return false; + } this.#queue.set(key, config); return true; } From ed9791c176a56ad9c6ba91768847f0dba2b5cda6 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 5 Apr 2026 09:04:47 +0300 Subject: [PATCH 20/32] feat(Font/lib): add FontNetworkError and FontResponseError --- src/entities/Font/lib/errors/errors.test.ts | 51 +++++++++++++++++++++ src/entities/Font/lib/errors/index.ts | 28 +++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/entities/Font/lib/errors/errors.test.ts create mode 100644 src/entities/Font/lib/errors/index.ts 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..1a1e22e --- /dev/null +++ b/src/entities/Font/lib/errors/errors.test.ts @@ -0,0 +1,51 @@ +import { + FontNetworkError, + FontResponseError, +} from './index'; + +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/index.ts b/src/entities/Font/lib/errors/index.ts new file mode 100644 index 0000000..4a49f96 --- /dev/null +++ b/src/entities/Font/lib/errors/index.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}`); + } +} From 9427f4e50f7f5dad648b7454ff5a35b3fd26cff8 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 5 Apr 2026 09:33:58 +0300 Subject: [PATCH 21/32] feat(Font): re-export FontNetworkError and FontResponseError from entity barrel --- src/entities/Font/index.ts | 5 +++++ src/entities/Font/lib/index.ts | 5 +++++ 2 files changed, 10 insertions(+) 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/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'; From b40e651be4dc86b91cc2b8ea8bea4546bbd92748 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 5 Apr 2026 11:02:42 +0300 Subject: [PATCH 22/32] refactor(Font/model): move baseFontStore and unifiedFontStore to subdirectories, rename errors/index to errors/errors --- src/entities/Font/lib/errors/errors.test.ts | 2 +- src/entities/Font/lib/errors/{index.ts => errors.ts} | 0 .../store/{ => baseFontStore}/baseFontStore.svelte.ts | 2 +- src/entities/Font/model/store/index.ts | 2 +- .../{ => unifiedFontStore}/unifiedFontStore.svelte.ts | 8 ++++---- 5 files changed, 7 insertions(+), 7 deletions(-) rename src/entities/Font/lib/errors/{index.ts => errors.ts} (100%) rename src/entities/Font/model/store/{ => baseFontStore}/baseFontStore.svelte.ts (99%) rename src/entities/Font/model/store/{ => unifiedFontStore}/unifiedFontStore.svelte.ts (97%) diff --git a/src/entities/Font/lib/errors/errors.test.ts b/src/entities/Font/lib/errors/errors.test.ts index 1a1e22e..a24c000 100644 --- a/src/entities/Font/lib/errors/errors.test.ts +++ b/src/entities/Font/lib/errors/errors.test.ts @@ -1,7 +1,7 @@ import { FontNetworkError, FontResponseError, -} from './index'; +} from './errors'; describe('FontNetworkError', () => { it('has correct name', () => { diff --git a/src/entities/Font/lib/errors/index.ts b/src/entities/Font/lib/errors/errors.ts similarity index 100% rename from src/entities/Font/lib/errors/index.ts rename to src/entities/Font/lib/errors/errors.ts diff --git a/src/entities/Font/model/store/baseFontStore.svelte.ts b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts similarity index 99% rename from src/entities/Font/model/store/baseFontStore.svelte.ts rename to src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts index 07c9d82..1db26ac 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 diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index 0e2e684..4f0fc38 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -11,7 +11,7 @@ export { createUnifiedFontStore, type UnifiedFontStore, unifiedFontStore, -} from './unifiedFontStore.svelte'; +} from './unifiedFontStore/unifiedFontStore.svelte'; // Applied fonts manager (CSS loading - unchanged) export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; diff --git a/src/entities/Font/model/store/unifiedFontStore.svelte.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts similarity index 97% rename from src/entities/Font/model/store/unifiedFontStore.svelte.ts rename to src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts index 33c7f07..8ba082f 100644 --- a/src/entities/Font/model/store/unifiedFontStore.svelte.ts +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts @@ -13,10 +13,10 @@ */ 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 type { UnifiedFont } from '../../types'; +import { BaseFontStore } from '../baseFontStore/baseFontStore.svelte'; /** * Unified font store wrapping TanStack Query with Svelte 5 runes From a1a1fcf39d9e93d7d972ecf685f2f20c3b1ff369 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 5 Apr 2026 11:03:00 +0300 Subject: [PATCH 23/32] feat(BaseFontStore): expose error getter --- .../Font/model/store/baseFontStore/baseFontStore.svelte.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts index 1db26ac..7f24b91 100644 --- a/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts +++ b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts @@ -117,6 +117,11 @@ 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; From df3c69490954bb2ed2704f14ad3cb3712cfbebd9 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 5 Apr 2026 14:06:32 +0300 Subject: [PATCH 24/32] feat(UnifiedFontStore): throw FontNetworkError and FontResponseError in fetchFn --- .../unifiedFontStore.svelte.ts | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts index 8ba082f..b7c9e99 100644 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts @@ -15,6 +15,10 @@ import type { QueryObserverOptions } from '@tanstack/query-core'; 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'; @@ -188,36 +192,29 @@ 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) { this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts]; } From 5df60b236c24a5dabd8a008364620726e78949fa Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 5 Apr 2026 14:59:17 +0300 Subject: [PATCH 25/32] test(UnifiedFontStore): cover fetchFn typed error paths and error getter --- .../unifiedFontStore/unifiedFontStore.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts diff --git a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts new file mode 100644 index 0000000..44e2200 --- /dev/null +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts @@ -0,0 +1,85 @@ +import { QueryClient } from '@tanstack/query-core'; +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 { fetchProxyFonts } from '../../../api'; +import { UnifiedFontStore } from './unifiedFontStore.svelte'; + +const mockedFetch = fetchProxyFonts as ReturnType; + +describe('UnifiedFontStore.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'); + }); +}); From 3ef012eb430a240efc1385786769a9dfa7b559bf Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 6 Apr 2026 11:34:03 +0300 Subject: [PATCH 26/32] test(UnifiedFontStore): add pagination state tests --- .../unifiedFontStore/unifiedFontStore.test.ts | 129 +++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts index 44e2200..c92f0c5 100644 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts @@ -28,12 +28,25 @@ vi.mock('../../../api', () => ({ })); import { queryClient } from '$shared/api/queryClient'; +import { flushSync } from 'svelte'; import { fetchProxyFonts } from '../../../api'; +import { generateMockFonts } from '../../../lib/mocks/fonts.mock'; +import type { UnifiedFont } from '../../types'; import { UnifiedFontStore } from './unifiedFontStore.svelte'; const mockedFetch = fetchProxyFonts as ReturnType; -describe('UnifiedFontStore.fetchFn error paths', () => { +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('fetchFn — error paths', () => { let store: UnifiedFontStore; beforeEach(() => { @@ -83,3 +96,117 @@ describe('UnifiedFontStore.fetchFn error paths', () => { 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(); + console.log('After first refetch + flushSync:', store.fonts.length); + + const second = generateMockFonts(2); + mockedFetch.mockResolvedValue(makeResponse(second)); + await store.refetch(); + console.log('After second refetch, before flushSync:', store.fonts.length, store.fonts.map(f => f.id)); + flushSync(); + console.log('After second refetch + flushSync:', store.fonts.length, store.fonts.map(f => f.id)); + + 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); + }); +}); From 6bf1b1ea87c931825f7698bb74f6573d1ac5b82c Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 6 Apr 2026 11:34:53 +0300 Subject: [PATCH 27/32] test(UnifiedFontStore): add pagination navigation tests --- .../unifiedFontStore/unifiedFontStore.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts index c92f0c5..38c8a4d 100644 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts @@ -210,3 +210,73 @@ describe('pagination state', () => { expect(store.pagination.totalPages).toBe(3); }); }); + +describe('pagination navigation', () => { + let store: UnifiedFontStore; + + beforeEach(async () => { + store = new UnifiedFontStore({ limit: 10 }); + mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 })); + await store.refetch(); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('nextPage() advances offset by limit when hasMore', () => { + store.nextPage(); + + 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(); + + store.nextPage(); + + expect(store.params.offset).toBe(10); + }); + + it('prevPage() decrements offset by limit when on page > 1', () => { + store.setParams({ offset: 10 }); + + store.prevPage(); + + expect(store.params.offset).toBe(0); + }); + + it('prevPage() does nothing on the first page', () => { + store.prevPage(); + + expect(store.params.offset).toBe(0); + }); + + it('goToPage() sets the correct offset', () => { + store.goToPage(2); + + expect(store.params.offset).toBe(10); + }); + + it('goToPage() does nothing for page 0', () => { + store.goToPage(0); + + expect(store.params.offset).toBe(0); + }); + + it('goToPage() does nothing for page beyond totalPages', () => { + store.goToPage(99); + + expect(store.params.offset).toBe(0); + }); + + it('setLimit() updates the limit param', () => { + store.setLimit(25); + + expect(store.params.limit).toBe(25); + }); +}); From 2ff7f1a13d4e5e75a32ee5c4087f88ca123fa660 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 6 Apr 2026 11:35:56 +0300 Subject: [PATCH 28/32] test(UnifiedFontStore): add filter setter tests --- .../unifiedFontStore/unifiedFontStore.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts index 38c8a4d..cf19a6c 100644 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts @@ -280,3 +280,54 @@ describe('pagination navigation', () => { 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']); + + expect(store.params.providers).toEqual(['google']); + }); + + it('setCategories() updates the categories param', () => { + store.setCategories(['serif']); + + expect(store.params.categories).toEqual(['serif']); + }); + + it('setSubsets() updates the subsets param', () => { + store.setSubsets(['cyrillic']); + + expect(store.params.subsets).toEqual(['cyrillic']); + }); + + it('setSearch() sets the q param', () => { + store.setSearch('roboto'); + + expect(store.params.q).toBe('roboto'); + }); + + it('setSearch() with empty string sets q to undefined', () => { + store.setSearch('roboto'); + store.setSearch(''); + + expect(store.params.q).toBeUndefined(); + }); + + it('setSort() updates the sort param', () => { + store.setSort('popularity'); + + expect(store.params.sort).toBe('popularity'); + }); +}); From fee3355a659398654f8d47d81bc8ce7826ac4bf1 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 6 Apr 2026 12:19:49 +0300 Subject: [PATCH 29/32] test(UnifiedFontStore): add filter change reset tests --- .../unifiedFontStore/unifiedFontStore.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts index cf19a6c..7b59d37 100644 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts @@ -1,4 +1,5 @@ import { QueryClient } from '@tanstack/query-core'; +import { tick } from 'svelte'; import { afterEach, beforeEach, @@ -331,3 +332,41 @@ describe('filter setters', () => { expect(store.params.sort).toBe('popularity'); }); }); + +describe('filter change resets pagination', () => { + let store: UnifiedFontStore; + + beforeEach(async () => { + store = new UnifiedFontStore({ limit: 10 }); + // Let the initial effect run so #previousFilterParams is set. + // Without this, the first filter change is treated as initialisation, not a reset. + await tick(); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('resets offset to 0 when a filter changes', async () => { + store.setParams({ offset: 20 }); + + store.setSearch('roboto'); + await tick(); + + 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(); + expect(store.fonts).toHaveLength(3); + + store.setSearch('roboto'); + await tick(); + + expect(store.fonts).toHaveLength(0); + }); +}); From 71fed58af90ac98be64883f76e2c27f028b220e0 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 6 Apr 2026 12:24:23 +0300 Subject: [PATCH 30/32] test(UnifiedFontStore): add category getter tests --- .../unifiedFontStore/unifiedFontStore.test.ts | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts index 7b59d37..360ae68 100644 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts @@ -31,7 +31,10 @@ vi.mock('../../../api', () => ({ import { queryClient } from '$shared/api/queryClient'; import { flushSync } from 'svelte'; import { fetchProxyFonts } from '../../../api'; -import { generateMockFonts } from '../../../lib/mocks/fonts.mock'; +import { + generateMixedCategoryFonts, + generateMockFonts, +} from '../../../lib/mocks/fonts.mock'; import type { UnifiedFont } from '../../types'; import { UnifiedFontStore } from './unifiedFontStore.svelte'; @@ -370,3 +373,63 @@ describe('filter change resets pagination', () => { 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); + }); +}); From 9c538069e4ba20e021f2d830a1af67135ccbe30c Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 6 Apr 2026 12:26:08 +0300 Subject: [PATCH 31/32] test(UnifiedFontStore): add isEmpty and destroy tests --- .../unifiedFontStore/unifiedFontStore.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts index 360ae68..d021769 100644 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts @@ -433,3 +433,58 @@ describe('category getters', () => { expect(store.monospaceFonts.every(f => f.category === 'monospace')).toBe(true); }); }); + +describe('isEmpty', () => { + let store: UnifiedFontStore; + + beforeEach(() => { + store = new UnifiedFontStore({ limit: 10 }); + }); + + afterEach(() => { + store.destroy(); + queryClient.clear(); + vi.resetAllMocks(); + }); + + it('is true when fetch returns no fonts', async () => { + mockedFetch.mockResolvedValue(makeResponse([])); + await store.refetch(); + + expect(store.isEmpty).toBe(true); + }); + + it('is false when fonts are present', async () => { + mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(3))); + await store.refetch(); + + expect(store.isEmpty).toBe(false); + }); + + it('is true after an error (no fonts loaded)', async () => { + mockedFetch.mockRejectedValue(new Error('network down')); + await store.refetch().catch((e: unknown) => e); + + expect(store.isEmpty).toBe(true); + }); +}); + +describe('destroy', () => { + it('can be called without throwing', () => { + const store = new UnifiedFontStore({ limit: 10 }); + + expect(() => { + store.destroy(); + }).not.toThrow(); + }); + + it('sets filterCleanup to null so it is not called again', () => { + const store = new UnifiedFontStore({ limit: 10 }); + store.destroy(); + + // Second destroy should not throw even though filterCleanup is now null + expect(() => { + store.destroy(); + }).not.toThrow(); + }); +}); From 752e38adf9e9f4a0a319ac42a69b204c9d2144ba Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 8 Apr 2026 09:33:04 +0300 Subject: [PATCH 32/32] test: full test coverage of baseFontStore and unifiedFontStore --- index.html | 1 + .../baseFontStore.svelte.spec.ts | 644 ++++++++++++++++++ .../baseFontStore/baseFontStore.svelte.ts | 65 +- .../unifiedFontStore.svelte.spec.ts | 474 +++++++++++++ .../unifiedFontStore.svelte.ts | 65 +- .../unifiedFontStore/unifiedFontStore.test.ts | 490 ------------- 6 files changed, 1208 insertions(+), 531 deletions(-) create mode 100644 src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.spec.ts create mode 100644 src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.spec.ts delete mode 100644 src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts 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/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/baseFontStore.svelte.ts b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts index 7f24b91..0a612ee 100644 --- a/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts +++ b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts @@ -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, }; @@ -127,25 +125,25 @@ export abstract class BaseFontStore> { 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()); } /** @@ -166,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(); } @@ -185,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/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/unifiedFontStore.svelte.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts index b7c9e99..85ee71b 100644 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts @@ -28,7 +28,7 @@ import { BaseFontStore } from '../baseFontStore/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 @@ -97,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 @@ -134,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; } @@ -215,7 +216,12 @@ export class UnifiedFontStore extends BaseFontStore { offset: response.offset ?? this.params.offset ?? 0, }; - 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]; } @@ -257,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/store/unifiedFontStore/unifiedFontStore.test.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts deleted file mode 100644 index d021769..0000000 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -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('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(); - console.log('After first refetch + flushSync:', store.fonts.length); - - const second = generateMockFonts(2); - mockedFetch.mockResolvedValue(makeResponse(second)); - await store.refetch(); - console.log('After second refetch, before flushSync:', store.fonts.length, store.fonts.map(f => f.id)); - flushSync(); - console.log('After second refetch + flushSync:', store.fonts.length, store.fonts.map(f => f.id)); - - 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 () => { - store = new UnifiedFontStore({ limit: 10 }); - mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 })); - await store.refetch(); - }); - - afterEach(() => { - store.destroy(); - queryClient.clear(); - vi.resetAllMocks(); - }); - - it('nextPage() advances offset by limit when hasMore', () => { - store.nextPage(); - - 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(); - - store.nextPage(); - - expect(store.params.offset).toBe(10); - }); - - it('prevPage() decrements offset by limit when on page > 1', () => { - store.setParams({ offset: 10 }); - - store.prevPage(); - - expect(store.params.offset).toBe(0); - }); - - it('prevPage() does nothing on the first page', () => { - store.prevPage(); - - expect(store.params.offset).toBe(0); - }); - - it('goToPage() sets the correct offset', () => { - store.goToPage(2); - - expect(store.params.offset).toBe(10); - }); - - it('goToPage() does nothing for page 0', () => { - store.goToPage(0); - - expect(store.params.offset).toBe(0); - }); - - it('goToPage() does nothing for page beyond totalPages', () => { - store.goToPage(99); - - expect(store.params.offset).toBe(0); - }); - - it('setLimit() updates the limit param', () => { - store.setLimit(25); - - 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']); - - expect(store.params.providers).toEqual(['google']); - }); - - it('setCategories() updates the categories param', () => { - store.setCategories(['serif']); - - expect(store.params.categories).toEqual(['serif']); - }); - - it('setSubsets() updates the subsets param', () => { - store.setSubsets(['cyrillic']); - - expect(store.params.subsets).toEqual(['cyrillic']); - }); - - it('setSearch() sets the q param', () => { - store.setSearch('roboto'); - - expect(store.params.q).toBe('roboto'); - }); - - it('setSearch() with empty string sets q to undefined', () => { - store.setSearch('roboto'); - store.setSearch(''); - - expect(store.params.q).toBeUndefined(); - }); - - it('setSort() updates the sort param', () => { - store.setSort('popularity'); - - expect(store.params.sort).toBe('popularity'); - }); -}); - -describe('filter change resets pagination', () => { - let store: UnifiedFontStore; - - beforeEach(async () => { - store = new UnifiedFontStore({ limit: 10 }); - // Let the initial effect run so #previousFilterParams is set. - // Without this, the first filter change is treated as initialisation, not a reset. - await tick(); - }); - - afterEach(() => { - store.destroy(); - queryClient.clear(); - vi.resetAllMocks(); - }); - - it('resets offset to 0 when a filter changes', async () => { - store.setParams({ offset: 20 }); - - store.setSearch('roboto'); - await tick(); - - 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(); - expect(store.fonts).toHaveLength(3); - - store.setSearch('roboto'); - await tick(); - - 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('isEmpty', () => { - let store: UnifiedFontStore; - - beforeEach(() => { - store = new UnifiedFontStore({ limit: 10 }); - }); - - afterEach(() => { - store.destroy(); - queryClient.clear(); - vi.resetAllMocks(); - }); - - it('is true when fetch returns no fonts', async () => { - mockedFetch.mockResolvedValue(makeResponse([])); - await store.refetch(); - - expect(store.isEmpty).toBe(true); - }); - - it('is false when fonts are present', async () => { - mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(3))); - await store.refetch(); - - expect(store.isEmpty).toBe(false); - }); - - it('is true after an error (no fonts loaded)', async () => { - mockedFetch.mockRejectedValue(new Error('network down')); - await store.refetch().catch((e: unknown) => e); - - expect(store.isEmpty).toBe(true); - }); -}); - -describe('destroy', () => { - it('can be called without throwing', () => { - const store = new UnifiedFontStore({ limit: 10 }); - - expect(() => { - store.destroy(); - }).not.toThrow(); - }); - - it('sets filterCleanup to null so it is not called again', () => { - const store = new UnifiedFontStore({ limit: 10 }); - store.destroy(); - - // Second destroy should not throw even though filterCleanup is now null - expect(() => { - store.destroy(); - }).not.toThrow(); - }); -});