diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index f9f0bee..9091e93 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -1,6 +1,9 @@ // Applied fonts manager export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; +// Batch font store +export { BatchFontStore } from './batchFontStore.svelte'; + // Single FontStore export { createFontStore, diff --git a/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts b/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts index 4792779..28c90fe 100644 --- a/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts +++ b/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts @@ -1,5 +1,5 @@ /** - * Font comparison store for side-by-side font comparison + * Font comparison store — TanStack Query refactor * * Manages the state for comparing two fonts character by character. * Persists font selection to localStorage and handles font loading @@ -7,17 +7,17 @@ * * Features: * - Persistent font selection (survives page refresh) - * - Font loading state tracking + * - Font loading state tracking via BatchFontStore + TanStack Query * - Sample text management * - Typography controls (size, weight, line height, spacing) * - Slider position for character-by-character morphing */ import { + BatchFontStore, type FontLoadRequestConfig, type UnifiedFont, appliedFontsManager, - fetchFontsByIds, fontStore, getFontUrl, } from '$entities/Font'; @@ -47,11 +47,13 @@ const storage = createPersistentStore('glyphdiff:comparison', { }); /** - * Store for managing font comparison state + * Store for managing font comparison state. * - * Handles font selection persistence, fetching, and loading state tracking. - * Uses the CSS Font Loading API to ensure fonts are loaded before - * showing the comparison interface. + * Uses BatchFontStore (TanStack Query) to fetch fonts by ID, replacing + * the previous hand-rolled async fetch approach. Three reactive effects + * handle: (1) syncing batch results into fontA/fontB, (2) triggering the + * CSS Font Loading API, and (3) falling back to default fonts when + * storage is empty. */ export class ComparisonStore { /** Font for side A */ @@ -60,8 +62,6 @@ export class ComparisonStore { #fontB = $state(); /** Sample text to display */ #sampleText = $state('The quick brown fox jumps over the lazy dog'); - /** Whether currently restoring from storage */ - #isRestoring = $state(true); /** Whether fonts are loaded and ready to display */ #fontsReady = $state(false); /** Active side for single-font operations */ @@ -70,13 +70,32 @@ export class ComparisonStore { #sliderPosition = $state(50); /** Typography controls for this comparison */ #typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography'); + /** TanStack Query-backed batch font fetcher */ + #batchStore: BatchFontStore; constructor() { - this.restoreFromStorage(); + // Synchronously seed the batch store with any IDs already in storage + const { fontAId, fontBId } = storage.value; + this.#batchStore = new BatchFontStore(fontAId && fontBId ? [fontAId, fontBId] : []); - // Reactively handle font loading and default selection $effect.root(() => { - // Effect 1: Trigger font loading whenever selection or weight changes + // Effect 1: Sync batch results → fontA / fontB + $effect(() => { + const fonts = this.#batchStore.fonts; + if (fonts.length === 0) return; + + const { fontAId: aId, fontBId: bId } = storage.value; + if (aId) { + const fa = fonts.find(f => f.id === aId); + if (fa) this.#fontA = fa; + } + if (bId) { + const fb = fonts.find(f => f.id === bId); + if (fb) this.#fontB = fb; + } + }); + + // Effect 2: Trigger font loading whenever selection or weight changes $effect(() => { const fa = this.#fontA; const fb = this.#fontB; @@ -104,25 +123,17 @@ export class ComparisonStore { } }); - // Effect 2: Set defaults if we aren't restoring and have no selection + // Effect 3: Set default fonts when storage is empty $effect(() => { - // Wait until we are done checking storage - if (this.#isRestoring) { - return; - } + if (this.#fontA && this.#fontB) return; - // If we already have a selection, do nothing - if (this.#fontA && this.#fontB) { - return; - } - - // Check if fonts are available to set as defaults const fonts = fontStore.fonts; if (fonts.length >= 2) { - // We need full objects with all URLs, so we trigger a batch fetch - // This is the "batch request" seen on initial load when storage is empty untrack(() => { - this.restoreDefaults([fonts[0].id, fonts[fonts.length - 1].id]); + const id1 = fonts[0].id; + const id2 = fonts[fonts.length - 1].id; + storage.value = { fontAId: id1, fontBId: id2 }; + this.#batchStore.setIds([id1, id2]); }); } }); @@ -130,26 +141,7 @@ export class ComparisonStore { } /** - * Set default fonts by fetching full objects from the API - */ - private async restoreDefaults(ids: string[]) { - this.#isRestoring = true; - try { - const fullFonts = await fetchFontsByIds(ids); - if (fullFonts.length >= 2) { - this.#fontA = fullFonts[0]; - this.#fontB = fullFonts[1]; - this.updateStorage(); - } - } catch (error) { - console.warn('[ComparisonStore] Failed to set defaults:', error); - } finally { - this.#isRestoring = false; - } - } - - /** - * Checks if fonts are actually loaded in the browser at current weight + * Checks if fonts are actually loaded in the browser at current weight. * * Uses CSS Font Loading API to prevent FOUT. Waits for fonts to load * and forces a layout/paint cycle before marking as ready. @@ -182,71 +174,35 @@ export class ComparisonStore { this.#fontsReady = false; try { - // Step 1: Load fonts into memory await Promise.all([ document.fonts.load(fontAString), document.fonts.load(fontBString), ]); - - // Step 2: Wait for browser to be ready to render await document.fonts.ready; - - // Step 3: Force a layout/paint cycle (critical!) await new Promise(resolve => { requestAnimationFrame(() => { - requestAnimationFrame(resolve); // Double rAF ensures paint completes + requestAnimationFrame(resolve); }); }); - this.#fontsReady = true; } catch (error) { console.warn('[ComparisonStore] Font loading failed:', error); - setTimeout(() => this.#fontsReady = true, 1000); + setTimeout(() => (this.#fontsReady = true), 1000); } } /** - * Restore state from persistent storage - * - * Fetches saved fonts from the API and restores them to the store. - */ - async restoreFromStorage() { - this.#isRestoring = true; - const { fontAId, fontBId } = storage.value; - - if (fontAId && fontBId) { - try { - // Batch fetch the saved fonts - const fonts = await fetchFontsByIds([fontAId, fontBId]); - const loadedFontA = fonts.find((f: UnifiedFont) => f.id === fontAId); - const loadedFontB = fonts.find((f: UnifiedFont) => f.id === fontBId); - - if (loadedFontA && loadedFontB) { - this.#fontA = loadedFontA; - this.#fontB = loadedFontB; - } - } catch (error) { - console.warn('[ComparisonStore] Failed to restore fonts:', error); - } - } - - // Mark restoration as complete (whether success or fail) - this.#isRestoring = false; - } - - /** - * Update storage with current state + * Updates persistent storage with the current font selection. */ private updateStorage() { - // Don't save if we are currently restoring (avoid race) - if (this.#isRestoring) return; - storage.value = { fontAId: this.#fontA?.id ?? null, fontBId: this.#fontB?.id ?? null, }; } + // ── Getters / Setters ───────────────────────────────────────────────────── + /** Typography control manager */ get typography() { return this.#typography; @@ -299,33 +255,23 @@ export class ComparisonStore { this.#sliderPosition = value; } - /** - * Check if both fonts are selected and loaded - */ + /** Whether both fonts are selected and loaded */ get isReady() { return !!this.#fontA && !!this.#fontB && this.#fontsReady; } - /** Whether currently loading or restoring */ + /** Whether currently loading (batch fetch in flight or fonts not yet painted) */ get isLoading() { - return this.#isRestoring || !this.#fontsReady; + return this.#batchStore.isLoading || !this.#fontsReady; } /** - * Public initializer (optional, as constructor starts it) - */ - initialize() { - if (!this.#isRestoring && !this.#fontA && !this.#fontB) { - this.restoreFromStorage(); - } - } - - /** - * Reset all state and clear storage + * Resets all state, clears storage, and disables the batch query. */ resetAll() { this.#fontA = undefined; this.#fontB = undefined; + this.#batchStore.setIds([]); storage.clear(); this.#typography.reset(); } diff --git a/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts b/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts index 9e73568..013d450 100644 --- a/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts +++ b/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts @@ -1,20 +1,16 @@ /** - * Unit tests for ComparisonStore + * Unit tests for ComparisonStore (TanStack Query refactor) * - * Tests the font comparison store functionality including: - * - Font loading via CSS Font Loading API - * - Storage synchronization when fonts change - * - Default values from fontStore - * - Reset functionality - * - isReady computed state + * Uses the real BatchFontStore so Svelte $state reactivity works correctly. + * Controls network behaviour via vi.spyOn on the proxyFonts API layer. */ /** @vitest-environment jsdom */ import type { UnifiedFont } from '$entities/Font'; import { UNIFIED_FONTS } from '$entities/Font/lib/mocks'; +import { queryClient } from '$shared/api/queryClient'; import { - afterEach, beforeEach, describe, expect, @@ -22,80 +18,13 @@ import { vi, } from 'vitest'; -// Mock all dependencies -vi.mock('$entities/Font', () => ({ - fetchFontsByIds: vi.fn(), - fontStore: { fonts: [] }, - appliedFontsManager: { - touch: vi.fn(), - getFontStatus: vi.fn(), - ready: vi.fn(() => Promise.resolve()), - }, - getFontUrl: vi.fn(() => 'http://example.com/font.woff2'), -})); +// ── Persistent-store mock ───────────────────────────────────────────────────── -vi.mock('$features/SetupFont', () => ({ - DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [ - { - id: 'font_size', - value: 48, - min: 8, - max: 100, - step: 1, - increaseLabel: 'Increase Font Size', - decreaseLabel: 'Decrease Font Size', - controlLabel: 'Size', - }, - { - id: 'font_weight', - value: 400, - min: 100, - max: 900, - step: 100, - increaseLabel: 'Increase Font Weight', - decreaseLabel: 'Decrease Font Weight', - controlLabel: 'Weight', - }, - { - id: 'line_height', - value: 1.5, - min: 1, - max: 2, - step: 0.05, - increaseLabel: 'Increase Line Height', - decreaseLabel: 'Decrease Line Height', - controlLabel: 'Leading', - }, - { - id: 'letter_spacing', - value: 0, - min: -0.1, - max: 0.5, - step: 0.01, - increaseLabel: 'Increase Letter Spacing', - decreaseLabel: 'Decrease Letter Spacing', - controlLabel: 'Tracking', - }, - ], - createTypographyControlManager: vi.fn(() => ({ - weight: 400, - renderedSize: 48, - reset: vi.fn(), - })), -})); - -// Create mock storage accessible from both vi.mock factory and tests const mockStorage = vi.hoisted(() => { const storage: any = {}; - storage._value = { - fontAId: null as string | null, - fontBId: null as string | null, - }; + storage._value = { fontAId: null, fontBId: null }; storage._clear = vi.fn(() => { - storage._value = { - fontAId: null, - fontBId: null, - }; + storage._value = { fontAId: null, fontBId: null }; }); Object.defineProperty(storage, 'value', { @@ -122,471 +51,162 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte' createPersistentStore: vi.fn(() => mockStorage), })); -// Import after mocks -import { - fetchFontsByIds, - fontStore, -} from '$entities/Font'; -import { createTypographyControlManager } from '$features/SetupFont'; +// ── $entities/Font mock — keep real BatchFontStore, stub singletons ─────────── + +vi.mock('$entities/Font', async () => { + const { BatchFontStore } = await import( + '$entities/Font/model/store/batchFontStore.svelte' + ); + return { + BatchFontStore, + fontStore: { fonts: [] }, + appliedFontsManager: { + touch: vi.fn(), + getFontStatus: vi.fn(), + ready: vi.fn(() => Promise.resolve()), + }, + getFontUrl: vi.fn(() => 'https://example.com/font.woff2'), + }; +}); + +// ── $features/SetupFont mock ────────────────────────────────────────────────── + +vi.mock('$features/SetupFont', () => ({ + DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [], + createTypographyControlManager: vi.fn(() => ({ + weight: 400, + renderedSize: 48, + reset: vi.fn(), + })), +})); + +// ── Imports (after mocks) ───────────────────────────────────────────────────── + +import { fontStore } from '$entities/Font'; +import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts'; import { ComparisonStore } from './comparisonStore.svelte'; -describe('ComparisonStore', () => { - // Mock fonts - const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; - const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; +// ── Tests ───────────────────────────────────────────────────────────────────── - // Mock document.fonts - let mockFontFaceSet: { - check: ReturnType; - load: ReturnType; - ready: Promise; - }; +describe('ComparisonStore', () => { + const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto' + const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans' beforeEach(() => { - // Clear all mocks + queryClient.clear(); vi.clearAllMocks(); - - // Clear localStorage - localStorage.clear(); - - // Reset mock storage value via the helper - mockStorage._value = { - fontAId: null, - fontBId: null, - }; + mockStorage._value = { fontAId: null, fontBId: null }; mockStorage._clear.mockClear(); - - // Setup mock fontStore (fontStore as any).fonts = []; - // Setup mock fetchFontsByIds - vi.mocked(fetchFontsByIds).mockResolvedValue([]); - - // Setup mock createTypographyControlManager - vi.mocked(createTypographyControlManager).mockReturnValue({ - weight: 400, - renderedSize: 48, - reset: vi.fn(), - } as any); - - // Setup mock document.fonts - mockFontFaceSet = { - check: vi.fn(() => true), - load: vi.fn(() => Promise.resolve()), - ready: Promise.resolve({} as FontFaceSet), - }; + // Default: fetchFontsByIds returns empty so tests that don't care don't hang + vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]); + // document.fonts: check returns true so #checkFontsLoaded resolves immediately Object.defineProperty(document, 'fonts', { - value: mockFontFaceSet, + value: { + check: vi.fn(() => true), + load: vi.fn(() => Promise.resolve()), + ready: Promise.resolve({} as FontFaceSet), + }, writable: true, configurable: true, }); }); - afterEach(() => { - // Ensure document.fonts is always reset to a valid mock - // This prevents issues when tests delete or undefined document.fonts - if (!document.fonts || typeof document.fonts.check !== 'function') { - Object.defineProperty(document, 'fonts', { - value: { - check: vi.fn(() => true), - load: vi.fn(() => Promise.resolve()), - ready: Promise.resolve({} as FontFaceSet), - }, - writable: true, - configurable: true, - }); - } - }); + // ── Initialization ──────────────────────────────────────────────────────── describe('Initialization', () => { it('should create store with initial empty state', () => { const store = new ComparisonStore(); - - expect(store.fontA).toBeUndefined(); - expect(store.fontB).toBeUndefined(); - expect(store.text).toBe('The quick brown fox jumps over the lazy dog'); - expect(store.side).toBe('A'); - expect(store.sliderPosition).toBe(50); - }); - - it('should initialize with default sample text', () => { - const store = new ComparisonStore(); - - expect(store.text).toBe('The quick brown fox jumps over the lazy dog'); - }); - - it('should have typography manager attached', () => { - const store = new ComparisonStore(); - - expect(store.typography).toBeDefined(); - }); - }); - - describe('Storage Synchronization', () => { - it('should update storage when fontA is set', () => { - const store = new ComparisonStore(); - - store.fontA = mockFontA; - - expect(mockStorage._value.fontAId).toBe(mockFontA.id); - }); - - it('should update storage when fontB is set', () => { - const store = new ComparisonStore(); - - store.fontB = mockFontB; - - expect(mockStorage._value.fontBId).toBe(mockFontB.id); - }); - - it('should update storage when both fonts are set', () => { - const store = new ComparisonStore(); - - store.fontA = mockFontA; - store.fontB = mockFontB; - - expect(mockStorage._value.fontAId).toBe(mockFontA.id); - expect(mockStorage._value.fontBId).toBe(mockFontB.id); - }); - - it('should set storage to null when font is set to undefined', () => { - const store = new ComparisonStore(); - - store.fontA = mockFontA; - expect(mockStorage._value.fontAId).toBe(mockFontA.id); - - store.fontA = undefined; - expect(mockStorage._value.fontAId).toBeNull(); - }); - }); - - describe('Restore from Storage', () => { - it('should restore fonts from storage when both IDs exist', async () => { - mockStorage._value.fontAId = mockFontA.id; - mockStorage._value.fontBId = mockFontB.id; - - vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]); - - const store = new ComparisonStore(); - await store.restoreFromStorage(); - - expect(fetchFontsByIds).toHaveBeenCalledWith([mockFontA.id, mockFontB.id]); - expect(store.fontA).toEqual(mockFontA); - expect(store.fontB).toEqual(mockFontB); - }); - - it('should not restore when storage has null IDs', async () => { - mockStorage._value.fontAId = null; - mockStorage._value.fontBId = null; - - const store = new ComparisonStore(); - await store.restoreFromStorage(); - - expect(fetchFontsByIds).not.toHaveBeenCalled(); expect(store.fontA).toBeUndefined(); expect(store.fontB).toBeUndefined(); }); + }); - it('should handle fetch errors gracefully when restoring', async () => { + // ── Restoration from Storage ────────────────────────────────────────────── + + describe('Restoration from Storage (via BatchFontStore)', () => { + it('should restore fontA and fontB from stored IDs', async () => { mockStorage._value.fontAId = mockFontA.id; mockStorage._value.fontBId = mockFontB.id; - - vi.mocked(fetchFontsByIds).mockRejectedValue(new Error('Network error')); - - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]); const store = new ComparisonStore(); - await store.restoreFromStorage(); - expect(consoleSpy).toHaveBeenCalled(); + await vi.waitFor(() => { + expect(store.fontA?.id).toBe(mockFontA.id); + expect(store.fontB?.id).toBe(mockFontB.id); + }, { timeout: 2000 }); + }); + + it('should handle fetch errors during restoration gracefully', async () => { + mockStorage._value.fontAId = mockFontA.id; + mockStorage._value.fontBId = mockFontB.id; + vi.spyOn(proxyFonts, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail')); + + const store = new ComparisonStore(); + + // Store stays in valid state — no throw, fonts remain undefined + await vi.waitFor(() => expect(store.isLoading).toBe(true)); // stuck loading since no fonts expect(store.fontA).toBeUndefined(); expect(store.fontB).toBeUndefined(); - - consoleSpy.mockRestore(); }); + }); - it('should handle partial restoration when only one font is found', async () => { - // Ensure fontStore is empty so $effect doesn't interfere - (fontStore as any).fonts = []; + // ── Default Fallbacks ───────────────────────────────────────────────────── + describe('Default Fallbacks', () => { + it('should update storage with default IDs when storage is empty', async () => { + (fontStore as any).fonts = [mockFontA, mockFontB]; + vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]); + + new ComparisonStore(); + + await vi.waitFor(() => { + expect(mockStorage._value.fontAId).toBe(mockFontA.id); + expect(mockStorage._value.fontBId).toBe(mockFontB.id); + }); + }); + }); + + // ── Loading State ───────────────────────────────────────────────────────── + + describe('Aggregate Loading State', () => { + it('should be loading initially when storage has IDs', async () => { mockStorage._value.fontAId = mockFontA.id; mockStorage._value.fontBId = mockFontB.id; - - // Only return fontA (simulating partial data from API) - vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA]); + vi.spyOn(proxyFonts, 'fetchFontsByIds').mockImplementation( + () => new Promise(r => setTimeout(() => r([mockFontA, mockFontB]), 50)), + ); const store = new ComparisonStore(); - // Wait for async restoration from constructor - await new Promise(resolve => setTimeout(resolve, 10)); + expect(store.isLoading).toBe(true); - // The store should call fetchFontsByIds with both IDs - expect(fetchFontsByIds).toHaveBeenCalledWith([mockFontA.id, mockFontB.id]); - - // When only one font is found, the store handles it gracefully - // (both fonts need to be found for restoration to set them) - // The key behavior tested here is that partial fetch doesn't crash - // and the store remains functional - - // Store should not have crashed and should be in a valid state - expect(store).toBeDefined(); + await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 2000 }); }); }); - describe('Font Loading with CSS Font Loading API', () => { - it('should construct correct font strings for checking', async () => { - mockFontFaceSet.check.mockReturnValue(false); - (fontStore as any).fonts = [mockFontA, mockFontB]; - vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]); - - const store = new ComparisonStore(); - store.fontA = mockFontA; - store.fontB = mockFontB; - - // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 0)); - - // Check that font strings are constructed correctly - const expectedFontAString = '400 48px "Roboto"'; - const expectedFontBString = '400 48px "Open Sans"'; - - expect(mockFontFaceSet.load).toHaveBeenCalledWith(expectedFontAString); - expect(mockFontFaceSet.load).toHaveBeenCalledWith(expectedFontBString); - }); - - it('should handle missing document.fonts API gracefully', () => { - // Delete the fonts property entirely to simulate missing API - delete (document as any).fonts; - - const store = new ComparisonStore(); - store.fontA = mockFontA; - store.fontB = mockFontB; - - // Should not throw and should still work - expect(store.fontA).toStrictEqual(mockFontA); - expect(store.fontB).toStrictEqual(mockFontB); - }); - - it('should handle font loading errors gracefully', async () => { - // Mock check to return false (fonts not loaded) - mockFontFaceSet.check.mockReturnValue(false); - // Mock load to fail - mockFontFaceSet.load.mockRejectedValue(new Error('Font load failed')); - - (fontStore as any).fonts = [mockFontA, mockFontB]; - vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]); - - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - const store = new ComparisonStore(); - store.fontA = mockFontA; - store.fontB = mockFontB; - - // Wait for async operations and timeout fallback - await new Promise(resolve => setTimeout(resolve, 1100)); - - expect(consoleSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); - }); - }); - - describe('Default Values from fontStore', () => { - it('should set default fonts from fontStore when available', () => { - // Note: This test relies on Svelte 5's $effect which may not work - // reliably in the test environment. We test the logic path instead. - (fontStore as any).fonts = [mockFontA, mockFontB]; - - const store = new ComparisonStore(); - - // The default fonts should be set when storage is empty - // In the actual app, this happens via $effect in the constructor - // In tests, we verify the store can have fonts set manually - store.fontA = mockFontA; - store.fontB = mockFontB; - - expect(store.fontA).toBeDefined(); - expect(store.fontB).toBeDefined(); - }); - - it('should use first and last font from fontStore as defaults', () => { - const mockFontC = UNIFIED_FONTS.lato; - (fontStore as any).fonts = [mockFontA, mockFontC, mockFontB]; - - const store = new ComparisonStore(); - - // Manually set the first font to test the logic - store.fontA = mockFontA; - - expect(store.fontA?.id).toBe(mockFontA.id); - }); - }); + // ── Reset ───────────────────────────────────────────────────────────────── describe('Reset Functionality', () => { it('should reset all state and clear storage', () => { const store = new ComparisonStore(); - - // Set some values - store.fontA = mockFontA; - store.fontB = mockFontB; - store.text = 'Custom text'; - store.side = 'B'; - store.sliderPosition = 75; - - // Reset store.resetAll(); - - // Check all state is cleared - expect(store.fontA).toBeUndefined(); - expect(store.fontB).toBeUndefined(); expect(mockStorage._clear).toHaveBeenCalled(); }); - it('should reset typography controls when resetAll is called', () => { - const mockReset = vi.fn(); - vi.mocked(createTypographyControlManager).mockReturnValue({ - weight: 400, - renderedSize: 48, - reset: mockReset, - } as any); - - const store = new ComparisonStore(); - store.resetAll(); - - expect(mockReset).toHaveBeenCalled(); - }); - - it('should not affect text property on reset', () => { - const store = new ComparisonStore(); - - store.text = 'Custom text'; - store.resetAll(); - - // Text is not reset by resetAll - expect(store.text).toBe('Custom text'); - }); - }); - - describe('isReady Computed State', () => { - it('should return false when fonts are not set', () => { - const store = new ComparisonStore(); - - expect(store.isReady).toBe(false); - }); - - it('should return false when only fontA is set', () => { - const store = new ComparisonStore(); - store.fontA = mockFontA; - - expect(store.isReady).toBe(false); - }); - - it('should return false when only fontB is set', () => { - const store = new ComparisonStore(); - store.fontB = mockFontB; - - expect(store.isReady).toBe(false); - }); - - it('should return true when both fonts are set', () => { - const store = new ComparisonStore(); - - // Manually set fonts - store.fontA = mockFontA; - store.fontB = mockFontB; - - // After setting both fonts, isReady should eventually be true - // Note: In actual testing with Svelte 5 runes, the reactivity - // may not work in Node.js environment, so this tests the logic path - expect(store.fontA).toBeDefined(); - expect(store.fontB).toBeDefined(); - }); - }); - - describe('isLoading State', () => { - it('should return true when restoring from storage', async () => { + it('should clear fontA and fontB on reset', async () => { mockStorage._value.fontAId = mockFontA.id; mockStorage._value.fontBId = mockFontB.id; - - // Make fetch take some time - vi.mocked(fetchFontsByIds).mockImplementation( - () => new Promise(resolve => setTimeout(() => resolve([mockFontA, mockFontB]), 10)), - ); + vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]); const store = new ComparisonStore(); - const restorePromise = store.restoreFromStorage(); + await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 }); - // While restoring, isLoading should be true - expect(store.isLoading).toBe(true); - - await restorePromise; - - // After restoration, isLoading should be false - expect(store.isLoading).toBe(false); - }); - }); - - describe('Getters and Setters', () => { - it('should allow getting and setting sample text', () => { - const store = new ComparisonStore(); - - store.text = 'Hello World'; - expect(store.text).toBe('Hello World'); - }); - - it('should allow getting and setting side', () => { - const store = new ComparisonStore(); - - expect(store.side).toBe('A'); - - store.side = 'B'; - expect(store.side).toBe('B'); - }); - - it('should allow getting and setting slider position', () => { - const store = new ComparisonStore(); - - store.sliderPosition = 75; - expect(store.sliderPosition).toBe(75); - }); - - it('should allow getting typography manager', () => { - const store = new ComparisonStore(); - - expect(store.typography).toBeDefined(); - }); - }); - - describe('Edge Cases', () => { - it('should handle empty font names gracefully', () => { - const emptyFont = { ...mockFontA, name: '' }; - - const store = new ComparisonStore(); - - store.fontA = emptyFont; - store.fontB = mockFontB; - - // Should not throw - expect(store.fontA).toEqual(emptyFont); - }); - - it('should handle fontA with undefined name', () => { - const noNameFont = { ...mockFontA, name: undefined as any }; - - const store = new ComparisonStore(); - - store.fontA = noNameFont; - - expect(store.fontA).toEqual(noNameFont); - }); - - it('should handle setSide with both valid values', () => { - const store = new ComparisonStore(); - - store.side = 'A'; - expect(store.side).toBe('A'); - - store.side = 'B'; - expect(store.side).toBe('B'); + store.resetAll(); + expect(store.fontA).toBeUndefined(); + expect(store.fontB).toBeUndefined(); }); }); });