diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index 084dff6..ef7f1e8 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -1,117 +1,5 @@ -// Proxy API (primary) -export { - fetchFontsByIds, - fetchProxyFontById, - fetchProxyFonts, -} from './api/proxy/proxyFonts'; -export type { - ProxyFontsParams, - ProxyFontsResponse, -} from './api/proxy/proxyFonts'; - -export { - normalizeFontshareFont, - normalizeFontshareFonts, -} from './lib/normalize/normalize'; -export type { - // Domain types - FontCategory, - FontCollectionFilters, - FontCollectionSort, - // Store types - FontCollectionState, - FontFeatures, - FontFiles, - FontItem, - FontMetadata, - FontProvider, - // Fontshare API types - FontshareApiModel, - FontshareAxis, - FontshareDesigner, - FontshareFeature, - FontshareFont, - FontshareLink, - FontsharePublisher, - FontshareStyle, - FontshareStyleProperties, - FontshareTag, - FontshareWeight, - FontStyleUrls, - FontSubset, - FontVariant, - FontWeight, - FontWeightItalic, - // Normalization types - UnifiedFont, - UnifiedFontVariant, -} from './model'; - export { appliedFontsManager, createFontStore, - createUnifiedFontStore, fontStore, - unifiedFontStore, } from './model'; - -// Mock data helpers for Storybook and testing -export { - createCategoriesFilter, - createErrorState, - createGenericFilter, - createLoadingState, - createMockComparisonStore, - // Filter mocks - createMockFilter, - createMockFontApiResponse, - createMockFontStoreState, - // Store mocks - createMockQueryState, - createMockReactiveState, - createMockStore, - createProvidersFilter, - createSubsetsFilter, - createSuccessState, - FONTHARE_FONTS, - generateMixedCategoryFonts, - generateMockFonts, - generatePaginatedFonts, - generateSequentialFilter, - GENERIC_FILTERS, - getAllMockFonts, - getFontsByCategory, - getFontsByProvider, - GOOGLE_FONTS, - MOCK_FILTERS, - MOCK_FILTERS_ALL_SELECTED, - MOCK_FILTERS_EMPTY, - MOCK_FILTERS_SELECTED, - MOCK_FONT_STORE_STATES, - MOCK_STORES, - type MockFilterOptions, - type MockFilters, - mockFontshareFont, - type MockFontshareFontOptions, - type MockFontStoreState, - // Font mocks - mockGoogleFont, - // Types - type MockGoogleFontOptions, - type MockQueryObserverResult, - type MockQueryState, - mockUnifiedFont, - type MockUnifiedFontOptions, - UNIFIED_FONTS, -} from './lib/mocks'; - -export { - FontNetworkError, - FontResponseError, -} from './lib/errors/errors'; - -// UI elements -export { - FontApplicator, - FontVirtualList, -} from './ui'; diff --git a/src/entities/Font/model/index.ts b/src/entities/Font/model/index.ts index 730126f..dfc737b 100644 --- a/src/entities/Font/model/index.ts +++ b/src/entities/Font/model/index.ts @@ -1,47 +1,6 @@ -export type { - // Domain types - FontCategory, - FontCollectionFilters, - FontCollectionSort, - // Store types - FontCollectionState, - FontFeatures, - FontFiles, - FontItem, - FontLoadRequestConfig, - FontLoadStatus, - FontMetadata, - FontProvider, - // Fontshare API types - FontshareApiModel, - FontshareAxis, - FontshareDesigner, - FontshareFeature, - FontshareFont, - FontshareLink, - FontsharePublisher, - FontshareStyle, - FontshareStyleProperties, - FontshareTag, - FontshareWeight, - FontStyleUrls, - FontSubset, - FontVariant, - FontWeight, - FontWeightItalic, - // Google Fonts API types - GoogleFontsApiModel, - // Normalization types - UnifiedFont, - UnifiedFontVariant, -} from './types'; - export { appliedFontsManager, createFontStore, - createUnifiedFontStore, FontStore, fontStore, - type UnifiedFontStore, - unifiedFontStore, } from './store'; diff --git a/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.spec.ts b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.spec.ts deleted file mode 100644 index d944d93..0000000 --- a/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.spec.ts +++ /dev/null @@ -1,644 +0,0 @@ -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 deleted file mode 100644 index 0a612ee..0000000 --- a/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { queryClient } from '$shared/api/queryClient'; -import { - type QueryKey, - QueryObserver, - type QueryObserverOptions, - type QueryObserverResult, -} from '@tanstack/query-core'; -import type { UnifiedFont } from '../../types'; - -/** - * Base class for font stores using TanStack Query - * - * Provides reactive font data fetching with caching, automatic refetching, - * and parameter binding. Extended by UnifiedFontStore for provider-agnostic - * font fetching. - * - * @template TParams - Type of query parameters - */ -export abstract class BaseFontStore> { - /** - * Cleanup function for effects - * Call destroy() to remove effects and prevent memory leaks - */ - cleanup: () => void; - - /** Internal parameter state */ - #internalParams = $state({} as TParams); - - /** - * Merged params from internal state - * Computed synchronously on access - */ - 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 result; - } - - /** TanStack Query result state */ - protected result = $state>({} as any); - /** TanStack Query observer instance */ - protected observer: QueryObserver; - /** Shared query client */ - protected qc = queryClient; - - /** - * Creates a new base font store - * @param initialParams - Initial query parameters - */ - constructor(initialParams: TParams) { - this.#internalParams = initialParams; - - this.observer = new QueryObserver(this.qc, this.getOptions()); - - // Sync TanStack Query state -> Svelte state - this.observer.subscribe(r => { - this.result = r; - }); - - // Sync Svelte state changes -> TanStack Query options - this.cleanup = $effect.root(() => { - $effect(() => { - this.observer.setOptions(this.getOptions()); - }); - }); - } - - /** - * Must be implemented by child class - * Returns the query key for TanStack Query caching - */ - protected abstract getQueryKey(params: TParams): QueryKey; - - /** - * Must be implemented by child class - * Fetches font data from API - */ - protected abstract fetchFn(params: TParams): Promise; - - /** - * Gets TanStack Query options - * @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(this.params), - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - }; - } - - /** Array of fonts (empty array if loading/error) */ - get fonts() { - return this.result.data ?? []; - } - - /** Whether currently fetching initial data */ - get isLoading() { - return this.result.isLoading; - } - - /** Whether any fetch is in progress (including refetches) */ - get isFetching() { - return this.result.isFetching; - } - - /** Whether last fetch resulted in an error */ - get isError() { - 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; - } - - /** - * Update query parameters - * @param newParams - Partial params to merge with existing - */ - setParams(newParams: Partial) { - 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()); - } - - /** - * Invalidate cache and refetch - */ - invalidate() { - this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) }); - } - - /** - * Clean up effects and observers - */ - destroy() { - this.cleanup(); - } - - /** - * 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(); - } - - /** - * Prefetch data with different parameters - */ - async prefetch(params: TParams) { - await this.qc.prefetchQuery(this.getOptions(params)); - } - - /** - * Cancel ongoing queries - */ - cancel() { - this.qc.cancelQueries({ - queryKey: this.getQueryKey(this.params), - }); - } - - /** - * Get cached data without triggering fetch - */ - getCachedData() { - return this.qc.getQueryData( - this.getQueryKey(this.params), - ); - } - - /** - * Set data manually (optimistic updates) - */ - setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) { - this.qc.setQueryData( - this.getQueryKey(this.params), - updater, - ); - } -} diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index a345174..72492af 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -6,13 +6,6 @@ * Single export point for the unified font store infrastructure. */ -// Primary store (unified) -export { - createUnifiedFontStore, - type UnifiedFontStore, - unifiedFontStore, -} 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/unifiedFontStore.svelte.spec.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.spec.ts deleted file mode 100644 index aa4eea6..0000000 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.spec.ts +++ /dev/null @@ -1,474 +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('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 deleted file mode 100644 index 85ee71b..0000000 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts +++ /dev/null @@ -1,427 +0,0 @@ -/** - * Unified font store - * - * Single source of truth for font data, powered by the proxy API. - * Extends BaseFontStore for TanStack Query integration and reactivity. - * - * Key features: - * - Provider-agnostic (proxy API handles provider logic) - * - Reactive to filter changes - * - Optimistic updates via TanStack Query - * - Pagination support - * - Provider-specific shortcuts for common operations - */ - -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'; - -/** - * Unified font store wrapping TanStack Query with Svelte 5 runes - * - * Extends BaseFontStore to provide: - * - Reactive state management - * - TanStack Query integration for caching - * - Filter change tracking with pagination reset - * - Pagination support - * - * @example - * ```ts - * const store = new UnifiedFontStore({ - * provider: 'google', - * category: 'sans-serif', - * limit: 50 - * }); - * - * // Access reactive state - * $effect(() => { - * console.log(store.fonts); - * console.log(store.isLoading); - * console.log(store.pagination); - * }); - * - * // Update parameters - * store.setCategories(['serif']); - * store.nextPage(); - * ``` - */ -export class UnifiedFontStore extends BaseFontStore { - /** - * Store pagination metadata separately from fonts - * This is a workaround for TanStack Query's type system - */ - #paginationMetadata = $state< - { - total: number; - limit: number; - offset: number; - } | null - >(null); - - /** - * Accumulated fonts from all pages (for infinite scroll) - */ - #accumulatedFonts = $state([]); - - /** - * Pagination metadata (derived from proxy API response) - */ - readonly pagination = $derived.by(() => { - if (this.#paginationMetadata) { - const { total, limit, offset } = this.#paginationMetadata; - return { - total, - limit, - offset, - hasMore: offset + limit < total, - page: Math.floor(offset / limit) + 1, - totalPages: Math.ceil(total / limit), - }; - } - return { - total: 0, - limit: this.params.limit || 50, - offset: this.params.offset || 0, - hasMore: false, - page: 1, - totalPages: 0, - }; - }); - - /** - * Track previous filter params to detect changes and reset pagination - */ - #previousFilterParams = $state(null); - - /** - * Cleanup function for the filter tracking effect - */ - #filterCleanup: (() => void) | null = null; - - constructor(initialParams: ProxyFontsParams = {}) { - super(initialParams); - - // Track filter params (excluding pagination params) - // Wrapped in $effect.root() to prevent effect_orphan error - this.#filterCleanup = $effect.root(() => { - $effect(() => { - const filterParams = JSON.stringify({ - providers: this.params.providers, - categories: this.params.categories, - subsets: this.params.subsets, - q: this.params.q, - }); - - // If filters changed, reset offset and invalidate cache - if (filterParams !== this.#previousFilterParams) { - if (this.#previousFilterParams) { - if (this.params.offset !== 0) { - this.setParams({ offset: 0 }); - } - this.#accumulatedFonts = []; - this.invalidate(); - } - this.#previousFilterParams = filterParams; - } - }); - - // Effect: Sync state from Query result (Handles Cache Hits) - $effect(() => { - const data = this.result.data; - 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; - } - }); - }); - } - - /** - * Clean up both parent and child effects - */ - destroy() { - // Call parent cleanup (TanStack observer effect) - super.destroy(); - - // Call filter tracking effect cleanup - if (this.#filterCleanup) { - this.#filterCleanup(); - this.#filterCleanup = null; - } - } - - /** - * Query key for TanStack Query caching - * Normalizes params to treat empty arrays/strings as undefined - */ - protected getQueryKey(params: ProxyFontsParams) { - // Normalize params to treat empty arrays/strings as undefined - const normalized = Object.entries(params).reduce((acc, [key, value]) => { - if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) { - return acc; - } - return { ...acc, [key]: value }; - }, {}); - - // Return a consistent key - return ['unifiedFonts', normalized] as const; - } - - protected getOptions(params = this.params): QueryObserverOptions { - const hasFilters = !!(params.q || params.providers || params.categories || params.subsets); - return { - queryKey: this.getQueryKey(params), - queryFn: () => this.fetchFn(params), - staleTime: hasFilters ? 0 : 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - }; - } - - /** - * Fetch function that calls the proxy API - * Returns the full response including pagination metadata - */ - protected async fetchFn(params: ProxyFontsParams): Promise { - let response: Awaited>; - try { - response = await fetchProxyFonts(params); - } catch (cause) { - throw new FontNetworkError(cause); - } - - if (!response) { - throw new FontResponseError('response', response); - } - if (!response.fonts) { - throw new FontResponseError('response.fonts', response.fonts); - } - if (!Array.isArray(response.fonts)) { - throw new FontResponseError('response.fonts', response.fonts); - } - - this.#paginationMetadata = { - total: response.total ?? 0, - limit: response.limit ?? this.params.limit ?? 50, - offset: response.offset ?? this.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]; - } - - return response.fonts; - } - - /** - * Get all accumulated fonts (for infinite scroll) - */ - get fonts(): UnifiedFont[] { - return this.#accumulatedFonts; - } - - /** - * Check if loading initial data - */ - get isLoading(): boolean { - return this.result.isLoading; - } - - /** - * Check if fetching (including background refetches) - */ - get isFetching(): boolean { - return this.result.isFetching; - } - - /** - * Check if error occurred - */ - get isError(): boolean { - return this.result.isError; - } - - /** - * Check if result is empty (not loading and no fonts) - */ - get isEmpty(): boolean { - 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 - */ - setProviders(providers: ProxyFontsParams['providers']) { - this.setParams({ providers }); - } - - /** - * Set categories filter - */ - setCategories(categories: ProxyFontsParams['categories']) { - this.setParams({ categories }); - } - - /** - * Set subsets filter - */ - setSubsets(subsets: ProxyFontsParams['subsets']) { - this.setParams({ subsets }); - } - - /** - * Set search query - */ - setSearch(search: string) { - this.setParams({ q: search || undefined }); - } - - /** - * Set sort order - */ - setSort(sort: ProxyFontsParams['sort']) { - this.setParams({ sort }); - } - - /** - * Go to next page - */ - nextPage() { - if (this.pagination.hasMore) { - this.setParams({ - offset: this.pagination.offset + this.pagination.limit, - }); - } - } - - /** - * Go to previous page - */ - prevPage() { - if (this.pagination.page > 1) { - this.setParams({ - offset: this.pagination.offset - this.pagination.limit, - }); - } - } - - /** - * Go to specific page - */ - goToPage(page: number) { - if (page >= 1 && page <= this.pagination.totalPages) { - this.setParams({ - offset: (page - 1) * this.pagination.limit, - }); - } - } - - /** - * Set limit (items per page) - */ - setLimit(limit: number) { - this.setParams({ limit }); - } - - get sansSerifFonts() { - return this.fonts.filter(f => f.category === 'sans-serif'); - } - - get serifFonts() { - return this.fonts.filter(f => f.category === 'serif'); - } - - get displayFonts() { - return this.fonts.filter(f => f.category === 'display'); - } - - get handwritingFonts() { - return this.fonts.filter(f => f.category === 'handwriting'); - } - - get monospaceFonts() { - return this.fonts.filter(f => f.category === 'monospace'); - } -} - -/** - * Factory function to create unified font store - */ -export function createUnifiedFontStore(params: ProxyFontsParams = {}) { - return new UnifiedFontStore(params); -} - -/** - * Singleton instance for global use - * Initialized with a default limit to prevent fetching all fonts at once - */ -export const unifiedFontStore = new UnifiedFontStore({ - limit: 50, - offset: 0, -});