diff --git a/.gitignore b/.gitignore index da06e93..bc723aa 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ node_modules /build /dist +# Git worktrees (isolated development branches) +.worktrees + # OS .DS_Store Thumbs.db diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index f4f0782..67e8053 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -1,115 +1,3 @@ -// 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, - createUnifiedFontStore, - 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'; +export * from './api'; +export * from './model'; +export * from './ui'; diff --git a/src/entities/Font/lib/mocks/stores.mock.ts b/src/entities/Font/lib/mocks/stores.mock.ts index 545af44..53f3dcb 100644 --- a/src/entities/Font/lib/mocks/stores.mock.ts +++ b/src/entities/Font/lib/mocks/stores.mock.ts @@ -20,7 +20,7 @@ * const successState = createMockQueryState({ status: 'success', data: mockFonts }); * * // Use preset stores - * const mockFontStore = MOCK_STORES.unifiedFontStore(); + * const mockFontStore = createMockFontStore(); * ``` */ @@ -459,6 +459,117 @@ export const MOCK_STORES = { resetFilters: () => {}, }; }, + /** + * Create a mock FontStore object + * Matches FontStore's public API for Storybook use + */ + fontStore: (config: { + fonts?: UnifiedFont[]; + total?: number; + limit?: number; + offset?: number; + isLoading?: boolean; + isFetching?: boolean; + isError?: boolean; + error?: Error | null; + hasMore?: boolean; + page?: number; + } = {}) => { + const { + fonts: mockFonts = Object.values(UNIFIED_FONTS).slice(0, 5), + total: mockTotal = mockFonts.length, + limit = 50, + offset = 0, + isLoading = false, + isFetching = false, + isError = false, + error = null, + hasMore = false, + page = 1, + } = config; + + const totalPages = Math.ceil(mockTotal / limit); + const state = { + params: { limit }, + }; + + return { + // State getters + get params() { + return state.params; + }, + get fonts() { + return mockFonts; + }, + get isLoading() { + return isLoading; + }, + get isFetching() { + return isFetching; + }, + get isError() { + return isError; + }, + get error() { + return error; + }, + get isEmpty() { + return !isLoading && !isFetching && mockFonts.length === 0; + }, + get pagination() { + return { + total: mockTotal, + limit, + offset, + hasMore, + page, + totalPages, + }; + }, + // Category getters + get sansSerifFonts() { + return mockFonts.filter(f => f.category === 'sans-serif'); + }, + get serifFonts() { + return mockFonts.filter(f => f.category === 'serif'); + }, + get displayFonts() { + return mockFonts.filter(f => f.category === 'display'); + }, + get handwritingFonts() { + return mockFonts.filter(f => f.category === 'handwriting'); + }, + get monospaceFonts() { + return mockFonts.filter(f => f.category === 'monospace'); + }, + // Lifecycle + destroy() {}, + // Param management + setParams(_updates: Record) {}, + invalidate() {}, + // Async operations (no-op for Storybook) + refetch() {}, + prefetch() {}, + cancel() {}, + getCachedData() { + return mockFonts.length > 0 ? mockFonts : undefined; + }, + setQueryData() {}, + // Filter shortcuts + setProviders() {}, + setCategories() {}, + setSubsets() {}, + setSearch() {}, + setSort() {}, + // Pagination navigation + nextPage() {}, + prevPage() {}, + goToPage() {}, + setLimit(_limit: number) { + state.params.limit = _limit; + }, + }; + }, }; // REACTIVE STATE MOCKS diff --git a/src/entities/Font/model/index.ts b/src/entities/Font/model/index.ts index 90eb6e3..5e0c11e 100644 --- a/src/entities/Font/model/index.ts +++ b/src/entities/Font/model/index.ts @@ -1,44 +1,7 @@ -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, - createUnifiedFontStore, - type UnifiedFontStore, - unifiedFontStore, + createFontStore, + FontStore, + fontStore, } from './store'; +export * from './types'; 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/fontStore/fontStore.svelte.spec.ts b/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts new file mode 100644 index 0000000..2b6f181 --- /dev/null +++ b/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts @@ -0,0 +1,583 @@ +import { QueryClient } from '@tanstack/query-core'; +import { flushSync } from 'svelte'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { + FontNetworkError, + FontResponseError, +} from '../../../lib/errors/errors'; +import { + generateMixedCategoryFonts, + generateMockFonts, +} from '../../../lib/mocks/fonts.mock'; +import type { UnifiedFont } from '../../types'; +import { FontStore } from './fontStore.svelte'; + +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'; + +const fetch = fetchProxyFonts as ReturnType; + +type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number }; + +const makeResponse = ( + fonts: UnifiedFont[], + meta: { total?: number; limit?: number; offset?: number } = {}, +): FontPage => ({ + fonts, + total: meta.total ?? fonts.length, + limit: meta.limit ?? 10, + offset: meta.offset ?? 0, +}); + +function makeStore(params = {}) { + return new FontStore({ limit: 10, ...params }); +} + +async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters[1] = {}) { + fetch.mockResolvedValue(makeResponse(fonts, meta)); + const store = makeStore(params); + await store.refetch(); + flushSync(); + return store; +} + +describe('FontStore', () => { + afterEach(() => { + queryClient.clear(); + vi.resetAllMocks(); + }); + + // ----------------------------------------------------------------------- + describe('construction', () => { + it('stores initial params', () => { + const store = makeStore({ limit: 20 }); + expect(store.params.limit).toBe(20); + store.destroy(); + }); + + it('defaults limit to 50 when not provided', () => { + const store = new FontStore(); + expect(store.params.limit).toBe(50); + store.destroy(); + }); + + it('starts with empty fonts', () => { + const store = makeStore(); + expect(store.fonts).toEqual([]); + store.destroy(); + }); + + it('starts with isEmpty false — initial fetch is in progress', () => { + // The observer starts fetching immediately on construction. + // isEmpty must be false so the UI shows a loader, not "no results". + const store = makeStore(); + expect(store.isEmpty).toBe(false); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('state after fetch', () => { + it('exposes loaded fonts', async () => { + const store = await fetchedStore({}, generateMockFonts(7)); + expect(store.fonts).toHaveLength(7); + store.destroy(); + }); + + it('isEmpty is false when fonts are present', async () => { + const store = await fetchedStore(); + expect(store.isEmpty).toBe(false); + store.destroy(); + }); + + it('isLoading is false after fetch', async () => { + const store = await fetchedStore(); + expect(store.isLoading).toBe(false); + store.destroy(); + }); + + it('isFetching is false after fetch', async () => { + const store = await fetchedStore(); + expect(store.isFetching).toBe(false); + store.destroy(); + }); + + it('isError is false on success', async () => { + const store = await fetchedStore(); + expect(store.isError).toBe(false); + store.destroy(); + }); + + it('error is null on success', async () => { + const store = await fetchedStore(); + expect(store.error).toBeNull(); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('error states', () => { + it('isError is false before any fetch', () => { + const store = makeStore(); + expect(store.isError).toBe(false); + store.destroy(); + }); + + it('wraps network failures in FontNetworkError', async () => { + fetch.mockRejectedValue(new Error('network down')); + const store = makeStore(); + await store.refetch().catch(() => {}); + flushSync(); + expect(store.error).toBeInstanceOf(FontNetworkError); + expect(store.isError).toBe(true); + store.destroy(); + }); + + it('exposes FontResponseError for falsy response', async () => { + const store = makeStore(); + fetch.mockResolvedValue(null); + await store.refetch().catch(() => {}); + flushSync(); + expect(store.error).toBeInstanceOf(FontResponseError); + expect((store.error as FontResponseError).field).toBe('response'); + store.destroy(); + }); + + it('exposes FontResponseError for missing fonts field', async () => { + fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 }); + const store = makeStore(); + await store.refetch().catch(() => {}); + flushSync(); + expect(store.error).toBeInstanceOf(FontResponseError); + expect((store.error as FontResponseError).field).toBe('response.fonts'); + store.destroy(); + }); + + it('exposes FontResponseError for non-array fonts', async () => { + fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 }); + const store = makeStore(); + await store.refetch().catch(() => {}); + flushSync(); + expect(store.error).toBeInstanceOf(FontResponseError); + expect((store.error as FontResponseError).received).toBe('bad'); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('font accumulation', () => { + it('replaces fonts when refetching the first page', async () => { + const store = makeStore(); + fetch.mockResolvedValue(makeResponse(generateMockFonts(3))); + await store.refetch(); + flushSync(); + + const second = generateMockFonts(2); + fetch.mockResolvedValue(makeResponse(second)); + await store.refetch(); + flushSync(); + + // refetch at offset=0 re-fetches all pages; only one page loaded → new data replaces old + expect(store.fonts).toHaveLength(2); + expect(store.fonts[0].id).toBe(second[0].id); + store.destroy(); + }); + + it('appends fonts after nextPage', async () => { + const page1 = generateMockFonts(3); + const store = await fetchedStore({ limit: 3 }, page1, { total: 6, limit: 3, offset: 0 }); + const page2 = generateMockFonts(3).map((f, i) => ({ ...f, id: `p2-${i}` })); + fetch.mockResolvedValue(makeResponse(page2, { total: 6, limit: 3, offset: 3 })); + await store.nextPage(); + flushSync(); + + expect(store.fonts).toHaveLength(6); + expect(store.fonts.slice(0, 3).map(f => f.id)).toEqual(page1.map(f => f.id)); + expect(store.fonts.slice(3).map(f => f.id)).toEqual(page2.map(f => f.id)); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('pagination state', () => { + it('returns zero-value defaults before any fetch', () => { + const store = makeStore(); + expect(store.pagination).toMatchObject({ total: 0, hasMore: false, page: 1, totalPages: 0 }); + store.destroy(); + }); + + it('reflects response metadata after fetch', async () => { + const store = await fetchedStore({}, generateMockFonts(10), { total: 30, limit: 10, offset: 0 }); + expect(store.pagination.total).toBe(30); + expect(store.pagination.hasMore).toBe(true); + expect(store.pagination.page).toBe(1); + expect(store.pagination.totalPages).toBe(3); + store.destroy(); + }); + + it('hasMore is false on the last page', async () => { + const store = await fetchedStore({}, generateMockFonts(10), { total: 10, limit: 10, offset: 0 }); + expect(store.pagination.hasMore).toBe(false); + store.destroy(); + }); + + it('page count increments after nextPage', async () => { + const store = await fetchedStore({ limit: 10 }, generateMockFonts(10), { total: 30, limit: 10, offset: 0 }); + expect(store.pagination.page).toBe(1); + + fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 })); + await store.nextPage(); + flushSync(); + expect(store.pagination.page).toBe(2); + + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('setParams', () => { + it('merges updates into existing params', () => { + const store = makeStore({ limit: 10 }); + store.setParams({ limit: 20 }); + expect(store.params.limit).toBe(20); + store.destroy(); + }); + + it('retains unmodified params', () => { + const store = makeStore({ limit: 10 }); + store.setCategories(['serif']); + store.setParams({ limit: 25 }); + expect(store.params.categories).toEqual(['serif']); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('filter change resets', () => { + it('clears accumulated fonts when a filter changes', async () => { + const store = await fetchedStore({}, generateMockFonts(5)); + store.setSearch('roboto'); + flushSync(); + // TQ switches to a new queryKey → data.pages reset → fonts = [] + expect(store.fonts).toHaveLength(0); + store.destroy(); + }); + + it('isEmpty is false immediately after filter change — fetch is in progress', async () => { + const store = await fetchedStore({}, generateMockFonts(5)); + // Hang the next fetch so we can observe the transitioning state + fetch.mockReturnValue(new Promise(() => {})); + store.setSearch('roboto'); + flushSync(); + // fonts = [] AND isFetching = true → isEmpty must be false (no "no results" flash) + expect(store.isEmpty).toBe(false); + store.destroy(); + }); + + it('does NOT reset fonts when the same filter value is set again', async () => { + const store = await fetchedStore({}, generateMockFonts(5)); + store.setCategories(['serif']); + flushSync(); + // First change: clears fonts (expected) + store.setCategories(['serif']); // same value — same queryKey — TQ keeps data.pages + flushSync(); + // Because queryKey hasn't changed, TQ returns cached data — fonts restored from cache + // (actual font count depends on cache; key assertion is no extra reset) + expect(store.isError).toBe(false); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('staleTime in buildOptions', () => { + it('is 5 minutes with no active filters', () => { + const store = makeStore(); + expect((store as any).buildOptions().staleTime).toBe(5 * 60 * 1000); + store.destroy(); + }); + + it('is 0 when a search query is active', () => { + const store = makeStore(); + store.setSearch('roboto'); + expect((store as any).buildOptions().staleTime).toBe(0); + store.destroy(); + }); + + it('is 0 when a category filter is active', () => { + const store = makeStore(); + store.setCategories(['serif']); + expect((store as any).buildOptions().staleTime).toBe(0); + store.destroy(); + }); + + it('gcTime is 10 minutes always', () => { + const store = makeStore(); + expect((store as any).buildOptions().gcTime).toBe(10 * 60 * 1000); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('buildQueryKey', () => { + it('omits empty-string params', () => { + const store = makeStore(); + store.setSearch(''); + const [root, normalized] = (store as any).buildQueryKey(store.params); + expect(root).toBe('fonts'); + expect(normalized).not.toHaveProperty('q'); + store.destroy(); + }); + + it('omits empty-array params', () => { + const store = makeStore(); + store.setProviders([]); + const [, normalized] = (store as any).buildQueryKey(store.params); + expect(normalized).not.toHaveProperty('providers'); + store.destroy(); + }); + + it('includes non-empty filter values', () => { + const store = makeStore(); + store.setCategories(['serif']); + const [, normalized] = (store as any).buildQueryKey(store.params); + expect(normalized).toHaveProperty('categories', ['serif']); + store.destroy(); + }); + + it('does not include offset (offset is the TQ page param, not a query key component)', () => { + const store = makeStore(); + const [, normalized] = (store as any).buildQueryKey(store.params); + expect(normalized).not.toHaveProperty('offset'); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('destroy', () => { + it('does not throw', () => { + const store = makeStore(); + expect(() => store.destroy()).not.toThrow(); + }); + + it('is idempotent', () => { + const store = makeStore(); + store.destroy(); + expect(() => store.destroy()).not.toThrow(); + }); + }); + + // ----------------------------------------------------------------------- + describe('refetch', () => { + it('triggers a fetch', async () => { + const store = makeStore(); + fetch.mockResolvedValue(makeResponse(generateMockFonts(3))); + await store.refetch(); + expect(fetch).toHaveBeenCalled(); + store.destroy(); + }); + + it('uses params current at call time', async () => { + const store = makeStore({ limit: 10 }); + store.setParams({ limit: 20 }); + fetch.mockResolvedValue(makeResponse(generateMockFonts(20))); + await store.refetch(); + expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 })); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('nextPage', () => { + let store: FontStore; + + beforeEach(async () => { + fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 })); + store = new FontStore({ limit: 10 }); + await store.refetch(); + flushSync(); + }); + + afterEach(() => { + store.destroy(); + }); + + it('fetches the next page and appends fonts', async () => { + fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 })); + await store.nextPage(); + flushSync(); + expect(store.fonts).toHaveLength(20); + expect(store.pagination.offset).toBe(10); + }); + + it('is a no-op when hasMore is false', async () => { + // Set up a store where all fonts fit in one page (hasMore = false) + queryClient.clear(); + fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 })); + store = new FontStore({ limit: 10 }); + await store.refetch(); + flushSync(); + + expect(store.pagination.hasMore).toBe(false); + await store.nextPage(); // should not trigger another fetch + expect(store.fonts).toHaveLength(10); + }); + }); + + // ----------------------------------------------------------------------- + describe('prevPage and goToPage', () => { + it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => { + const store = await fetchedStore({}, generateMockFonts(5)); + store.prevPage(); + expect(store.fonts).toHaveLength(5); // unchanged + store.destroy(); + }); + + it('goToPage is a no-op — infinite scroll does not support arbitrary page jumps', async () => { + const store = await fetchedStore({}, generateMockFonts(5)); + store.goToPage(3); + expect(store.fonts).toHaveLength(5); // unchanged + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('prefetch', () => { + it('triggers a fetch for the provided params', async () => { + const store = makeStore(); + fetch.mockResolvedValue(makeResponse(generateMockFonts(5))); + await store.prefetch({ limit: 5 }); + expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 5, offset: 0 })); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('getCachedData / setQueryData', () => { + it('getCachedData returns undefined before any fetch', () => { + queryClient.clear(); + const store = new FontStore({ limit: 10 }); + expect(store.getCachedData()).toBeUndefined(); + store.destroy(); + }); + + it('getCachedData returns flattened fonts after fetch', async () => { + const store = await fetchedStore(); + expect(store.getCachedData()).toHaveLength(5); + store.destroy(); + }); + + it('setQueryData writes to cache', () => { + const store = makeStore(); + const font = generateMockFonts(1)[0]; + store.setQueryData(() => [font]); + expect(store.getCachedData()).toHaveLength(1); + store.destroy(); + }); + + it('setQueryData updater receives existing flattened fonts', async () => { + const store = await fetchedStore(); + const updater = vi.fn((old: UnifiedFont[] | undefined) => old ?? []); + store.setQueryData(updater); + expect(updater).toHaveBeenCalledWith(expect.any(Array)); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('invalidate', () => { + it('calls invalidateQueries', async () => { + const store = await fetchedStore(); + const spy = vi.spyOn(queryClient, 'invalidateQueries'); + store.invalidate(); + expect(spy).toHaveBeenCalledOnce(); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('setLimit', () => { + it('updates the limit param', () => { + const store = makeStore({ limit: 10 }); + store.setLimit(25); + expect(store.params.limit).toBe(25); + store.destroy(); + }); + }); + + // ----------------------------------------------------------------------- + describe('filter shortcut methods', () => { + let store: FontStore; + + beforeEach(() => { + store = makeStore(); + }); + afterEach(() => { + store.destroy(); + }); + + it('setProviders updates providers param', () => { + store.setProviders(['google']); + expect(store.params.providers).toEqual(['google']); + }); + + it('setCategories updates categories param', () => { + store.setCategories(['serif']); + expect(store.params.categories).toEqual(['serif']); + }); + + it('setSubsets updates subsets param', () => { + store.setSubsets(['cyrillic']); + expect(store.params.subsets).toEqual(['cyrillic']); + }); + + it('setSearch sets q param', () => { + store.setSearch('roboto'); + expect(store.params.q).toBe('roboto'); + }); + + it('setSearch with empty string clears q', () => { + store.setSearch('roboto'); + store.setSearch(''); + expect(store.params.q).toBeUndefined(); + }); + + it('setSort updates sort param', () => { + store.setSort('popularity'); + expect(store.params.sort).toBe('popularity'); + }); + }); + + // ----------------------------------------------------------------------- + describe('category getters', () => { + it('each getter returns only fonts of that category', async () => { + const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total + fetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length })); + const store = makeStore({ limit: 50 }); + await store.refetch(); + flushSync(); + + expect(store.sansSerifFonts.every(f => f.category === 'sans-serif')).toBe(true); + expect(store.serifFonts.every(f => f.category === 'serif')).toBe(true); + expect(store.displayFonts.every(f => f.category === 'display')).toBe(true); + expect(store.handwritingFonts.every(f => f.category === 'handwriting')).toBe(true); + expect(store.monospaceFonts.every(f => f.category === 'monospace')).toBe(true); + expect(store.sansSerifFonts).toHaveLength(2); + + store.destroy(); + }); + }); +}); diff --git a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts new file mode 100644 index 0000000..95edee0 --- /dev/null +++ b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts @@ -0,0 +1,283 @@ +import { queryClient } from '$shared/api/queryClient'; +import { + type InfiniteData, + InfiniteQueryObserver, + type InfiniteQueryObserverResult, + type QueryFunctionContext, +} from '@tanstack/query-core'; +import { + type ProxyFontsParams, + type ProxyFontsResponse, + fetchProxyFonts, +} from '../../../api'; +import { + FontNetworkError, + FontResponseError, +} from '../../../lib/errors/errors'; +import type { UnifiedFont } from '../../types'; + +type PageParam = { offset: number }; + +/** Filter params + limit — offset is managed by TQ as a page param, not a user param. */ +type FontStoreParams = Omit; + +type FontStoreResult = InfiniteQueryObserverResult, Error>; + +export class FontStore { + #params = $state({ limit: 50 }); + #result = $state({} as FontStoreResult); + #observer: InfiniteQueryObserver< + ProxyFontsResponse, + Error, + InfiniteData, + readonly unknown[], + PageParam + >; + #qc = queryClient; + #unsubscribe: () => void; + + constructor(params: FontStoreParams = {}) { + this.#params = { limit: 50, ...params }; + this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions()); + this.#unsubscribe = this.#observer.subscribe(r => { + this.#result = r; + }); + } + + // -- Public state -- + + get params(): FontStoreParams { + return this.#params; + } + get fonts(): UnifiedFont[] { + return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? []; + } + get isLoading(): boolean { + return this.#result.isLoading; + } + get isFetching(): boolean { + return this.#result.isFetching; + } + get isError(): boolean { + return this.#result.isError; + } + + get error(): Error | null { + return this.#result.error ?? null; + } + // isEmpty is false during loading/fetching so the UI never flashes "no results" + // while a fetch is in progress. The !isFetching guard is specifically for the filter-change + // transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false. + get isEmpty(): boolean { + return !this.isLoading && !this.isFetching && this.fonts.length === 0; + } + + get pagination() { + const pages = this.#result.data?.pages; + const last = pages?.at(-1); + if (!last) { + return { + total: 0, + limit: this.#params.limit ?? 50, + offset: 0, + hasMore: false, + page: 1, + totalPages: 0, + }; + } + return { + total: last.total, + limit: last.limit, + offset: last.offset, + hasMore: this.#result.hasNextPage, + page: pages!.length, + totalPages: Math.ceil(last.total / last.limit), + }; + } + + // -- Lifecycle -- + + destroy() { + this.#unsubscribe(); + this.#observer.destroy(); + } + + // -- Param management -- + + setParams(updates: Partial) { + this.#params = { ...this.#params, ...updates }; + this.#observer.setOptions(this.buildOptions()); + } + invalidate() { + this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) }); + } + + // -- Async operations -- + + async refetch() { + await this.#observer.refetch(); + } + + async prefetch(params: FontStoreParams) { + await this.#qc.prefetchInfiniteQuery(this.buildOptions(params)); + } + + cancel() { + this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) }); + } + + getCachedData(): UnifiedFont[] | undefined { + const data = this.#qc.getQueryData>( + this.buildQueryKey(this.#params), + ); + if (!data) return undefined; + return data.pages.flatMap(p => p.fonts); + } + + setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) { + const key = this.buildQueryKey(this.#params); + this.#qc.setQueryData>( + key, + old => { + const flatFonts = old?.pages.flatMap(p => p.fonts); + const newFonts = updater(flatFonts); + // Re-distribute the updated fonts back into the existing page structure + // Define the first page. If old data exists, we merge into the first page template. + const limit = typeof this.#params.limit === 'number' ? this.#params.limit : 50; + const template = old?.pages[0] ?? { + total: newFonts.length, + limit, + offset: 0, + }; + + const updatedPage: ProxyFontsResponse = { + ...template, + fonts: newFonts, + total: newFonts.length, // Synchronize total with the new font count + }; + + return { + pages: [updatedPage], + pageParams: [{ offset: 0 }], + }; + }, + ); + } + + // -- Filter shortcuts -- + + setProviders(v: ProxyFontsParams['providers']) { + this.setParams({ providers: v }); + } + setCategories(v: ProxyFontsParams['categories']) { + this.setParams({ categories: v }); + } + setSubsets(v: ProxyFontsParams['subsets']) { + this.setParams({ subsets: v }); + } + setSearch(v: string) { + this.setParams({ q: v || undefined }); + } + setSort(v: ProxyFontsParams['sort']) { + this.setParams({ sort: v }); + } + + // -- Pagination navigation -- + + async nextPage(): Promise { + await this.#observer.fetchNextPage(); + } + prevPage(): void {} // no-op: infinite scroll accumulates forward only; method kept for API compatibility + goToPage(_page: number): void {} // no-op + + setLimit(limit: number) { + this.setParams({ limit }); + } + + // -- Category views -- + + 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'); + } + + // -- Private helpers (TypeScript-private so tests can spy via `as any`) -- + + private buildQueryKey(params: FontStoreParams): readonly unknown[] { + const filtered: Record = {}; + + for (const [key, value] of Object.entries(params)) { + // Ensure we DO NOT 'continue' or skip the limit key here. + // The limit is a fundamental part of the data identity. + if ( + value !== undefined + && value !== null + && value !== '' + && !(Array.isArray(value) && value.length === 0) + ) { + filtered[key] = value; + } + } + + return ['fonts', filtered]; + } + + private buildOptions(params = this.#params) { + const activeParams = { ...params }; + const hasFilters = !!( + activeParams.q + || (Array.isArray(activeParams.providers) && activeParams.providers.length > 0) + || (Array.isArray(activeParams.categories) && activeParams.categories.length > 0) + || (Array.isArray(activeParams.subsets) && activeParams.subsets.length > 0) + ); + return { + queryKey: this.buildQueryKey(activeParams), + queryFn: ({ pageParam }: QueryFunctionContext) => + this.fetchPage({ ...activeParams, ...pageParam }), + initialPageParam: { offset: 0 } as PageParam, + getNextPageParam: (lastPage: ProxyFontsResponse): PageParam | undefined => { + const next = lastPage.offset + lastPage.limit; + return next < lastPage.total ? { offset: next } : undefined; + }, + staleTime: hasFilters ? 0 : 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }; + } + + private async fetchPage(params: ProxyFontsParams): Promise { + let response: ProxyFontsResponse; + 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); + + return { + fonts: response.fonts, + total: response.total ?? 0, + limit: response.limit ?? params.limit ?? 50, + offset: response.offset ?? params.offset ?? 0, + }; + } +} + +export function createFontStore(params: FontStoreParams = {}): FontStore { + return new FontStore(params); +} + +export const fontStore = new FontStore({ limit: 50 }); diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index 4f0fc38..72492af 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -6,12 +6,12 @@ * 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'; + +// Single FontStore (new implementation) +export { + createFontStore, + FontStore, + fontStore, +} from './fontStore/fontStore.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, -}); diff --git a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte index 9277a38..9e41143 100644 --- a/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte +++ b/src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte @@ -18,7 +18,7 @@ import { type FontLoadRequestConfig, type UnifiedFont, appliedFontsManager, - unifiedFontStore, + fontStore, } from '../../model'; interface Props extends @@ -50,7 +50,7 @@ let { }: Props = $props(); const isLoading = $derived( - unifiedFontStore.isFetching || unifiedFontStore.isLoading, + fontStore.isFetching || fontStore.isLoading, ); function handleInternalVisibleChange(visibleItems: UnifiedFont[]) { @@ -82,12 +82,12 @@ function handleInternalVisibleChange(visibleItems: UnifiedFont[]) { */ function loadMore() { if ( - !unifiedFontStore.pagination.hasMore - || unifiedFontStore.isFetching + !fontStore.pagination.hasMore + || fontStore.isFetching ) { return; } - unifiedFontStore.nextPage(); + fontStore.nextPage(); } /** @@ -97,17 +97,17 @@ function loadMore() { * of the loaded items. Only fetches if there are more pages available. */ function handleNearBottom(_lastVisibleIndex: number) { - const { hasMore } = unifiedFontStore.pagination; + const { hasMore } = fontStore.pagination; // VirtualList already checks if we're near the bottom of loaded items - if (hasMore && !unifiedFontStore.isFetching) { + if (hasMore && !fontStore.isFetching) { loadMore(); } }
- {#if skeleton && isLoading && unifiedFontStore.fonts.length === 0} + {#if skeleton && isLoading && fontStore.fonts.length === 0}
{@render skeleton()} @@ -115,8 +115,8 @@ function handleNearBottom(_lastVisibleIndex: number) { {:else}