/** * Unit tests for ComparisonStore (TanStack Query refactor) * * Uses the real FontsByIdsStore 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 { beforeEach, describe, expect, it, vi, } from 'vitest'; const mockStorage = vi.hoisted(() => { const storage: any = {}; storage._value = { fontAId: null, fontBId: null }; storage._clear = vi.fn(() => { storage._value = { fontAId: null, fontBId: null }; }); Object.defineProperty(storage, 'value', { get() { return storage._value; }, set(v: any) { storage._value = v; }, enumerable: true, configurable: true, }); Object.defineProperty(storage, 'clear', { value: storage._clear, enumerable: true, configurable: true, }); return storage; }); vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte', () => ({ createPersistentStore: vi.fn(() => mockStorage), })); vi.mock('$entities/Font', async importOriginal => { const actual = await importOriginal(); return { ...actual, fontCatalogStore: { fonts: [] }, fontLifecycleManager: { touch: vi.fn(), pin: vi.fn(), unpin: vi.fn(), getFontStatus: vi.fn(), ready: vi.fn(() => Promise.resolve()), }, getFontUrl: vi.fn(() => 'https://example.com/font.woff2'), }; }); vi.mock('$features/AdjustTypography', () => ({ DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [], createTypographyControlManager: vi.fn(() => ({ weight: 400, renderedSize: 48, reset: vi.fn(), })), })); vi.mock('$features/AdjustTypography/model', () => ({ typographySettingsStore: { weight: 400, renderedSize: 48, reset: vi.fn(), }, })); import { fontCatalogStore, fontLifecycleManager, } from '$entities/Font'; import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts'; import { ComparisonStore } from './comparisonStore.svelte'; describe('ComparisonStore', () => { const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto' const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans' beforeEach(() => { queryClient.clear(); vi.clearAllMocks(); mockStorage._value = { fontAId: null, fontBId: null }; mockStorage._clear.mockClear(); (fontCatalogStore as any).fonts = []; // 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: { check: vi.fn(() => true), load: vi.fn(() => Promise.resolve()), ready: Promise.resolve({} as FontFaceSet), }, writable: true, configurable: true, }); }); describe('Initialization', () => { it('should create store with initial empty state', () => { const store = new ComparisonStore(); expect(store.fontA).toBeUndefined(); expect(store.fontB).toBeUndefined(); }); }); describe('Restoration from Storage (via FontsByIdsStore)', () => { it('should restore fontA and fontB from stored IDs', async () => { mockStorage._value.fontAId = mockFontA.id; mockStorage._value.fontBId = mockFontB.id; vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]); const store = new ComparisonStore(); 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(); }); }); describe('Default Fallbacks', () => { it('should update storage with default IDs when storage is empty', async () => { (fontCatalogStore 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); }); }); }); describe('Aggregate Loading State', () => { it('should be loading initially when storage has IDs', async () => { mockStorage._value.fontAId = mockFontA.id; mockStorage._value.fontBId = mockFontB.id; vi.spyOn(proxyFonts, 'fetchFontsByIds').mockImplementation( () => new Promise(r => setTimeout(() => r([mockFontA, mockFontB]), 50)), ); const store = new ComparisonStore(); expect(store.isLoading).toBe(true); await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 2000 }); }); }); describe('Reset Functionality', () => { it('should reset all state and clear storage', () => { const store = new ComparisonStore(); store.resetAll(); expect(mockStorage._clear).toHaveBeenCalled(); }); it('should clear fontA and fontB on reset', async () => { mockStorage._value.fontAId = mockFontA.id; mockStorage._value.fontBId = mockFontB.id; vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]); const store = new ComparisonStore(); await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 }); store.resetAll(); expect(store.fontA).toBeUndefined(); expect(store.fontB).toBeUndefined(); }); }); describe('Pin / Unpin (eviction guard)', () => { it('pins fontA and fontB when they are loaded', async () => { mockStorage._value.fontAId = mockFontA.id; mockStorage._value.fontBId = mockFontB.id; vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]); new ComparisonStore(); await vi.waitFor(() => { expect(fontLifecycleManager.pin).toHaveBeenCalledWith( mockFontA.id, 400, mockFontA.features?.isVariable, ); expect(fontLifecycleManager.pin).toHaveBeenCalledWith( mockFontB.id, 400, mockFontB.features?.isVariable, ); }, { timeout: 2000 }); }); it('unpins the old font when fontA is replaced', async () => { mockStorage._value.fontAId = mockFontA.id; mockStorage._value.fontBId = mockFontB.id; vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]); const store = new ComparisonStore(); await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 }); const mockFontC: typeof mockFontA = { ...mockFontA, id: 'playfair', name: 'Playfair Display' }; vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontC, mockFontB]); store.fontA = mockFontC; await vi.waitFor(() => { expect(fontLifecycleManager.unpin).toHaveBeenCalledWith( mockFontA.id, 400, mockFontA.features?.isVariable, ); expect(fontLifecycleManager.pin).toHaveBeenCalledWith( mockFontC.id, 400, mockFontC.features?.isVariable, ); }, { timeout: 2000 }); }); }); });