From dc6e15492a1b1606183d9ea19dbcf2a5873fe205 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 9 Apr 2026 19:40:31 +0300 Subject: [PATCH] test: mock fontStore and update FontStore type signatures --- src/entities/Font/index.ts | 8 +- src/entities/Font/lib/mocks/stores.mock.ts | 113 +++++++++++++++++- src/entities/Font/model/index.ts | 1 + .../store/fontStore/fontStore.svelte.spec.ts | 22 ++-- .../model/store/fontStore/fontStore.svelte.ts | 89 ++++++++------ .../model/stores/comparisonStore.test.ts | 1 + 6 files changed, 180 insertions(+), 54 deletions(-) diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index ef7f1e8..67e8053 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -1,5 +1,3 @@ -export { - appliedFontsManager, - createFontStore, - fontStore, -} from './model'; +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 dfc737b..5e0c11e 100644 --- a/src/entities/Font/model/index.ts +++ b/src/entities/Font/model/index.ts @@ -4,3 +4,4 @@ export { FontStore, fontStore, } from './store'; +export * from './types'; 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 d571544..2b6f181 100644 --- a/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts +++ b/src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts @@ -48,8 +48,8 @@ function makeStore(params = {}) { } async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters[1] = {}) { - const store = makeStore(params); fetch.mockResolvedValue(makeResponse(fonts, meta)); + const store = makeStore(params); await store.refetch(); flushSync(); return store; @@ -138,8 +138,8 @@ describe('FontStore', () => { }); it('wraps network failures in FontNetworkError', async () => { - const store = makeStore(); fetch.mockRejectedValue(new Error('network down')); + const store = makeStore(); await store.refetch().catch(() => {}); flushSync(); expect(store.error).toBeInstanceOf(FontNetworkError); @@ -158,8 +158,8 @@ describe('FontStore', () => { }); it('exposes FontResponseError for missing fonts field', async () => { - const store = makeStore(); fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 }); + const store = makeStore(); await store.refetch().catch(() => {}); flushSync(); expect(store.error).toBeInstanceOf(FontResponseError); @@ -168,8 +168,8 @@ describe('FontStore', () => { }); it('exposes FontResponseError for non-array fonts', async () => { - const store = makeStore(); fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 }); + const store = makeStore(); await store.refetch().catch(() => {}); flushSync(); expect(store.error).toBeInstanceOf(FontResponseError); @@ -198,12 +198,8 @@ describe('FontStore', () => { }); 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 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(); @@ -240,10 +236,7 @@ describe('FontStore', () => { }); 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(); + 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 })); @@ -571,10 +564,11 @@ describe('FontStore', () => { // ----------------------------------------------------------------------- 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 })); + 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); diff --git a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts index a48ef54..95edee0 100644 --- a/src/entities/Font/model/store/fontStore/fontStore.svelte.ts +++ b/src/entities/Font/model/store/fontStore/fontStore.svelte.ts @@ -16,28 +16,23 @@ import { } from '../../../lib/errors/errors'; 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>; +type FontStoreResult = InfiniteQueryObserverResult, Error>; export class FontStore { #params = $state({ limit: 50 }); #result = $state({} as FontStoreResult); - #observer: InfiniteQueryObserver, readonly unknown[], PageParam>; + #observer: InfiniteQueryObserver< + ProxyFontsResponse, + Error, + InfiniteData, + readonly unknown[], + PageParam + >; #qc = queryClient; #unsubscribe: () => void; @@ -55,7 +50,7 @@ export class FontStore { return this.#params; } get fonts(): UnifiedFont[] { - return this.#result.data?.pages.flatMap((p: FontPage) => p.fonts) ?? []; + return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? []; } get isLoading(): boolean { return this.#result.isLoading; @@ -120,7 +115,6 @@ export class FontStore { // -- Async operations -- async refetch() { - this.#observer.setOptions(this.buildOptions()); await this.#observer.refetch(); } @@ -133,7 +127,7 @@ export class FontStore { } getCachedData(): UnifiedFont[] | undefined { - const data = this.#qc.getQueryData>( + const data = this.#qc.getQueryData>( this.buildQueryKey(this.#params), ); if (!data) return undefined; @@ -141,15 +135,30 @@ export class FontStore { } setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) { - this.#qc.setQueryData>( - this.buildQueryKey(this.#params), + const key = this.buildQueryKey(this.#params); + this.#qc.setQueryData>( + key, 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 }; + // 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: [{ ...template, fonts: newFonts }], - pageParams: [{ offset: 0 } as PageParam], + pages: [updatedPage], + pageParams: [{ offset: 0 }], }; }, ); @@ -206,26 +215,38 @@ export class FontStore { // -- Private helpers (TypeScript-private so tests can spy via `as any`) -- private buildQueryKey(params: FontStoreParams): readonly unknown[] { - 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; + 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 = !!( - 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) + 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(params), + queryKey: this.buildQueryKey(activeParams), queryFn: ({ pageParam }: QueryFunctionContext) => - this.fetchPage({ ...this.#params, ...pageParam }), + this.fetchPage({ ...activeParams, ...pageParam }), initialPageParam: { offset: 0 } as PageParam, - getNextPageParam: (lastPage: FontPage): PageParam | undefined => { + getNextPageParam: (lastPage: ProxyFontsResponse): PageParam | undefined => { const next = lastPage.offset + lastPage.limit; return next < lastPage.total ? { offset: next } : undefined; }, @@ -234,7 +255,7 @@ export class FontStore { }; } - private async fetchPage(params: ProxyFontsParams): Promise { + private async fetchPage(params: ProxyFontsParams): Promise { let response: ProxyFontsResponse; try { response = await fetchProxyFonts(params); diff --git a/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts b/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts index c2e5223..9d397b8 100644 --- a/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts +++ b/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts @@ -26,6 +26,7 @@ import { vi.mock('$entities/Font', () => ({ fetchFontsByIds: vi.fn(), unifiedFontStore: { fonts: [] }, + fontStore: { fonts: [] }, })); vi.mock('$features/SetupFont', () => ({