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..7229645 --- /dev/null +++ b/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts @@ -0,0 +1,592 @@ +import { + type InfiniteData, + 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] = {}) { + const store = makeStore(params); + fetch.mockResolvedValue(makeResponse(fonts, meta)); + 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 () => { + const store = makeStore(); + fetch.mockRejectedValue(new Error('network down')); + 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 () => { + const store = makeStore(); + fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 }); + 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 () => { + const store = makeStore(); + fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 }); + 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 store = makeStore(); + const page1 = generateMockFonts(3); + fetch.mockResolvedValue(makeResponse(page1, { total: 6, limit: 3, offset: 0 })); + await store.refetch(); + flushSync(); + + 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 = makeStore(); + fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 })); + await store.refetch(); + flushSync(); + 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 store = makeStore({ limit: 50 }); + const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total + fetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length })); + await store.refetch(); + + 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..fa1111f --- /dev/null +++ b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts @@ -0,0 +1,149 @@ +import { queryClient } from '$shared/api/queryClient'; +import { + type InfiniteData, + InfiniteQueryObserver, + type InfiniteQueryObserverResult, + type QueryFunctionContext, +} from '@tanstack/query-core'; +import type { ProxyFontsParams } from '../../../api'; +import type { UnifiedFont } from '../../types'; + +/** + * Shape of a single page as returned by the proxy API and stored by TQ. + * Each entry in `result.data.pages` is a `FontPage`. + */ +type FontPage = { + fonts: UnifiedFont[]; + total: number; + limit: number; + offset: number; +}; + +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, 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 []; + } + get isLoading(): boolean { + return false; + } + get isFetching(): boolean { + return false; + } + get isError(): boolean { + return false; + } + get error(): Error | null { + return null; + } + get isEmpty(): boolean { + return false; + } + + get pagination() { + return { total: 0, limit: 50, offset: 0, hasMore: false, page: 1, totalPages: 0 }; + } + + // -- Lifecycle -- + + destroy(): void {} + + // -- Param management -- + + setParams(_updates: Partial): void {} + invalidate(): void {} + + // -- Async operations -- + + async refetch(): Promise {} + async prefetch(_params: FontStoreParams): Promise {} + cancel(): void {} + getCachedData(): UnifiedFont[] | undefined { + return undefined; + } + setQueryData(_updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]): void {} + + // -- Filter shortcuts -- + + setProviders(_v: ProxyFontsParams['providers']): void {} + setCategories(_v: ProxyFontsParams['categories']): void {} + setSubsets(_v: ProxyFontsParams['subsets']): void {} + setSearch(_v: string): void {} + setSort(_v: ProxyFontsParams['sort']): void {} + + // -- Pagination navigation -- + + async nextPage(): Promise {} + prevPage(): void {} // no-op: infinite scroll only loads forward + goToPage(_page: number): void {} // no-op + setLimit(_limit: number): void {} + + // -- Category views -- + + get sansSerifFonts(): UnifiedFont[] { + return []; + } + get serifFonts(): UnifiedFont[] { + return []; + } + get displayFonts(): UnifiedFont[] { + return []; + } + get handwritingFonts(): UnifiedFont[] { + return []; + } + get monospaceFonts(): UnifiedFont[] { + return []; + } + + // -- Private helpers (TypeScript-private so tests can spy via `as any`) -- + + private buildQueryKey(params: FontStoreParams): readonly unknown[] { + return ['fonts', params]; + } + + private buildOptions(params = this.#params) { + return { + queryKey: this.buildQueryKey(params), + queryFn: ({ pageParam }: QueryFunctionContext) => + this.fetchPage({ ...this.#params, ...pageParam }), + initialPageParam: { offset: 0 } as PageParam, + getNextPageParam: (_lastPage: FontPage): PageParam | undefined => undefined, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }; + } + + private async fetchPage(_params: ProxyFontsParams): Promise { + return { fonts: [], total: 0, limit: 0, offset: 0 }; + } +} + +export function createFontStore(params: FontStoreParams = {}): FontStore { + return new FontStore(params); +}