From 7517678e878fcfa2bbf6720ae83616b2867ac9de Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 8 Apr 2026 09:37:47 +0300 Subject: [PATCH 01/10] chore: add .worktrees to .gitignore for isolated development --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 -- 2.49.1 From 9a9ff95bf30228d9009eebd41d5b8e9e74f2432c Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 8 Apr 2026 09:43:29 +0300 Subject: [PATCH 02/10] test(FontStore): write full TDD spec and empty shell (InfiniteQueryObserver) --- .../store/fontStore/fontStore.svelte.spec.ts | 592 ++++++++++++++++++ .../model/store/fontStore/fontStore.svelte.ts | 149 +++++ 2 files changed, 741 insertions(+) create mode 100644 src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts create mode 100644 src/entities/Font/model/store/fontStore/fontStore.svelte.ts 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); +} -- 2.49.1 From 778988977f49c63f59eb52409381ac2bc3926057 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 8 Apr 2026 09:47:25 +0300 Subject: [PATCH 03/10] feat(FontStore): implement state getters, pagination, buildQueryKey, buildOptions --- .../store/fontStore/fontStore.svelte.spec.ts | 5 +- .../model/store/fontStore/fontStore.svelte.ts | 56 +++++++++++++++---- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts b/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts index 7229645..d571544 100644 --- a/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts +++ b/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts @@ -1,7 +1,4 @@ -import { - type InfiniteData, - QueryClient, -} from '@tanstack/query-core'; +import { QueryClient } from '@tanstack/query-core'; import { flushSync } from 'svelte'; import { afterEach, diff --git a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts index fa1111f..78ff5f3 100644 --- a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts +++ b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts @@ -47,26 +47,49 @@ export class FontStore { return this.#params; } get fonts(): UnifiedFont[] { - return []; + return this.#result.data?.pages.flatMap((p: FontPage) => p.fonts) ?? []; } get isLoading(): boolean { - return false; + return this.#result.isLoading; } get isFetching(): boolean { - return false; + return this.#result.isFetching; } get isError(): boolean { - return false; + return this.#result.isError; } + get error(): Error | null { - return 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 false; + return !this.isLoading && !this.isFetching && this.fonts.length === 0; } get pagination() { - return { total: 0, limit: 50, offset: 0, hasMore: false, page: 1, totalPages: 0 }; + 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 -- @@ -124,17 +147,30 @@ export class FontStore { // -- Private helpers (TypeScript-private so tests can spy via `as any`) -- private buildQueryKey(params: FontStoreParams): readonly unknown[] { - return ['fonts', params]; + const normalized = Object.entries(params).reduce>((acc, [k, v]) => { + if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) return acc; + return { ...acc, [k]: v }; + }, {}); + return ['fonts', normalized] as const; } private buildOptions(params = this.#params) { + const hasFilters = !!( + params.q + || (Array.isArray(params.providers) && params.providers.length > 0) + || (Array.isArray(params.categories) && params.categories.length > 0) + || (Array.isArray(params.subsets) && params.subsets.length > 0) + ); 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, + getNextPageParam: (lastPage: FontPage): 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, }; } -- 2.49.1 From a9e4633b64a7870793763a3c4621851837b694bf Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 8 Apr 2026 09:50:16 +0300 Subject: [PATCH 04/10] feat(FontStore): implement fetchPage with error wrapping --- .../model/store/fontStore/fontStore.svelte.ts | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts index 78ff5f3..9b10bf7 100644 --- a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts +++ b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts @@ -5,7 +5,15 @@ import { type InfiniteQueryObserverResult, type QueryFunctionContext, } from '@tanstack/query-core'; -import type { ProxyFontsParams } from '../../../api'; +import { + type ProxyFontsParams, + type ProxyFontsResponse, + fetchProxyFonts, +} from '../../../api'; +import { + FontNetworkError, + FontResponseError, +} from '../../../lib/errors/errors'; import type { UnifiedFont } from '../../types'; /** @@ -175,8 +183,24 @@ export class FontStore { }; } - private async fetchPage(_params: ProxyFontsParams): Promise { - return { fonts: [], total: 0, limit: 0, offset: 0 }; + 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, + }; } } -- 2.49.1 From 2a761b9d478ce6d51e0dbec3d3b80cd70f8eff8d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 8 Apr 2026 09:54:27 +0300 Subject: [PATCH 05/10] =?UTF-8?q?feat(FontStore):=20implement=20lifecycle,?= =?UTF-8?q?=20param=20management,=20async=20methods,=20shortcuts,=20pagina?= =?UTF-8?q?tion,=20category=20getters,=20singleton=20=E2=80=94=20all=20tes?= =?UTF-8?q?ts=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/store/fontStore/fontStore.svelte.ts | 107 +++++++++++++----- 1 file changed, 80 insertions(+), 27 deletions(-) diff --git a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts index 9b10bf7..a48ef54 100644 --- a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts +++ b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts @@ -102,54 +102,105 @@ export class FontStore { // -- Lifecycle -- - destroy(): void {} + destroy() { + this.#unsubscribe(); + this.#observer.destroy(); + } // -- Param management -- - setParams(_updates: Partial): void {} - invalidate(): void {} + 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(): Promise {} - async prefetch(_params: FontStoreParams): Promise {} - cancel(): void {} - getCachedData(): UnifiedFont[] | undefined { - return undefined; + async refetch() { + this.#observer.setOptions(this.buildOptions()); + 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[]) { + this.#qc.setQueryData>( + this.buildQueryKey(this.#params), + old => { + const flatFonts = old?.pages.flatMap(p => p.fonts); + const newFonts = updater(flatFonts); + const template = old?.pages[0] ?? { total: newFonts.length, limit: newFonts.length, offset: 0 }; + return { + pages: [{ ...template, fonts: newFonts }], + pageParams: [{ offset: 0 } as PageParam], + }; + }, + ); } - 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 {} + 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 {} - prevPage(): void {} // no-op: infinite scroll only loads forward + 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): void {} + + setLimit(limit: number) { + this.setParams({ limit }); + } // -- Category views -- - get sansSerifFonts(): UnifiedFont[] { - return []; + get sansSerifFonts() { + return this.fonts.filter(f => f.category === 'sans-serif'); } - get serifFonts(): UnifiedFont[] { - return []; + get serifFonts() { + return this.fonts.filter(f => f.category === 'serif'); } - get displayFonts(): UnifiedFont[] { - return []; + get displayFonts() { + return this.fonts.filter(f => f.category === 'display'); } - get handwritingFonts(): UnifiedFont[] { - return []; + get handwritingFonts() { + return this.fonts.filter(f => f.category === 'handwriting'); } - get monospaceFonts(): UnifiedFont[] { - return []; + get monospaceFonts() { + return this.fonts.filter(f => f.category === 'monospace'); } // -- Private helpers (TypeScript-private so tests can spy via `as any`) -- @@ -207,3 +258,5 @@ export class FontStore { export function createFontStore(params: FontStoreParams = {}): FontStore { return new FontStore(params); } + +export const fontStore = new FontStore({ limit: 50 }); -- 2.49.1 From 468d2e7f8c33fbd2459652480647cc7eeaa3a7b2 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 8 Apr 2026 09:55:40 +0300 Subject: [PATCH 06/10] feat(FontStore): export through entity barrel files --- src/entities/Font/index.ts | 2 ++ src/entities/Font/model/index.ts | 3 +++ src/entities/Font/model/store/index.ts | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index f4f0782..084dff6 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -49,7 +49,9 @@ export type { export { appliedFontsManager, + createFontStore, createUnifiedFontStore, + fontStore, unifiedFontStore, } from './model'; diff --git a/src/entities/Font/model/index.ts b/src/entities/Font/model/index.ts index 90eb6e3..730126f 100644 --- a/src/entities/Font/model/index.ts +++ b/src/entities/Font/model/index.ts @@ -38,7 +38,10 @@ export type { export { appliedFontsManager, + createFontStore, createUnifiedFontStore, + FontStore, + fontStore, type UnifiedFontStore, unifiedFontStore, } from './store'; diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index 4f0fc38..a345174 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -15,3 +15,10 @@ export { // Applied fonts manager (CSS loading - unchanged) export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; + +// Single FontStore (new implementation) +export { + createFontStore, + FontStore, + fontStore, +} from './fontStore/fontStore.svelte'; -- 2.49.1 From ed7d31bf5cea23066a2af47d9966d9ff2c2bdf91 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 8 Apr 2026 10:00:30 +0300 Subject: [PATCH 07/10] refactor: migrate all callers from unifiedFontStore to fontStore --- .../ui/FontVirtualList/FontVirtualList.svelte | 20 +++++++++---------- .../ui/FiltersControl/FilterControls.svelte | 4 ++-- .../model/stores/comparisonStore.svelte.ts | 4 ++-- .../ui/FontSearch/FontSearch.svelte | 4 ++-- .../SampleListSection.svelte | 4 ++-- 5 files changed, 18 insertions(+), 18 deletions(-) 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}