From 752e38adf9e9f4a0a319ac42a69b204c9d2144ba Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 8 Apr 2026 09:33:04 +0300 Subject: [PATCH] test: full test coverage of baseFontStore and unifiedFontStore --- index.html | 1 + .../baseFontStore.svelte.spec.ts | 644 ++++++++++++++++++ .../baseFontStore/baseFontStore.svelte.ts | 65 +- .../unifiedFontStore.svelte.spec.ts | 474 +++++++++++++ .../unifiedFontStore.svelte.ts | 65 +- .../unifiedFontStore/unifiedFontStore.test.ts | 490 ------------- 6 files changed, 1208 insertions(+), 531 deletions(-) create mode 100644 src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.spec.ts create mode 100644 src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.spec.ts delete mode 100644 src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts diff --git a/index.html b/index.html index ddd2d2c..1772631 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ glyphdiff +
diff --git a/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.spec.ts b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.spec.ts new file mode 100644 index 0000000..d944d93 --- /dev/null +++ b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.spec.ts @@ -0,0 +1,644 @@ +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 index 7f24b91..0a612ee 100644 --- a/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts +++ b/src/entities/Font/model/store/baseFontStore/baseFontStore.svelte.ts @@ -23,25 +23,22 @@ export abstract class BaseFontStore> { */ cleanup: () => void; - /** Reactive parameter bindings from external sources */ - #bindings = $state<(() => Partial)[]>([]); /** Internal parameter state */ #internalParams = $state({} as TParams); /** - * Merged params from internal state and all bindings - * Automatically updates when bindings or internal params change + * Merged params from internal state + * Computed synchronously on access */ - params = $derived.by(() => { - let merged = { ...this.#internalParams }; - - // Merge all binding results into params - for (const getter of this.#bindings) { - const bindingResult = getter(); - merged = { ...merged, ...bindingResult }; + 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 merged as TParams; - }); + + return result; + } /** TanStack Query result state */ protected result = $state>({} as any); @@ -89,9 +86,10 @@ export abstract class BaseFontStore> { * @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(params), + queryFn: () => this.fetchFn(this.params), staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, }; @@ -127,25 +125,25 @@ export abstract class BaseFontStore> { return !this.isLoading && this.fonts.length === 0; } - /** - * Add a reactive parameter binding - * @param getter - Function that returns partial params to merge - * @returns Unbind function to remove the binding - */ - addBinding(getter: () => Partial) { - this.#bindings.push(getter); - - return () => { - this.#bindings = this.#bindings.filter(b => b !== getter); - }; - } - /** * Update query parameters * @param newParams - Partial params to merge with existing */ setParams(newParams: Partial) { - this.#internalParams = { ...this.params, ...newParams }; + 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()); } /** @@ -166,6 +164,8 @@ export abstract class BaseFontStore> { * 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(); } @@ -185,15 +185,6 @@ export abstract class BaseFontStore> { }); } - /** - * Clear cache for current params - */ - clearCache() { - this.qc.removeQueries({ - queryKey: this.getQueryKey(this.params), - }); - } - /** * Get cached data without triggering fetch */ diff --git a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.spec.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.spec.ts new file mode 100644 index 0000000..aa4eea6 --- /dev/null +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.spec.ts @@ -0,0 +1,474 @@ +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 index b7c9e99..85ee71b 100644 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts +++ b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.svelte.ts @@ -28,7 +28,7 @@ import { BaseFontStore } from '../baseFontStore/baseFontStore.svelte'; * Extends BaseFontStore to provide: * - Reactive state management * - TanStack Query integration for caching - * - Dynamic parameter binding for filters + * - Filter change tracking with pagination reset * - Pagination support * * @example @@ -97,7 +97,7 @@ export class UnifiedFontStore extends BaseFontStore { /** * Track previous filter params to detect changes and reset pagination */ - #previousFilterParams = $state(''); + #previousFilterParams = $state(null); /** * Cleanup function for the filter tracking effect @@ -134,11 +134,12 @@ export class UnifiedFontStore extends BaseFontStore { // Effect: Sync state from Query result (Handles Cache Hits) $effect(() => { const data = this.result.data; - const offset = this.params.offset || 0; + 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; } @@ -215,7 +216,12 @@ export class UnifiedFontStore extends BaseFontStore { offset: response.offset ?? this.params.offset ?? 0, }; - if (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]; } @@ -257,6 +263,57 @@ export class UnifiedFontStore extends BaseFontStore { 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 */ diff --git a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts b/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts deleted file mode 100644 index d021769..0000000 --- a/src/entities/Font/model/store/unifiedFontStore/unifiedFontStore.test.ts +++ /dev/null @@ -1,490 +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('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(); - console.log('After first refetch + flushSync:', store.fonts.length); - - const second = generateMockFonts(2); - mockedFetch.mockResolvedValue(makeResponse(second)); - await store.refetch(); - console.log('After second refetch, before flushSync:', store.fonts.length, store.fonts.map(f => f.id)); - flushSync(); - console.log('After second refetch + flushSync:', store.fonts.length, store.fonts.map(f => f.id)); - - 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 () => { - store = new UnifiedFontStore({ limit: 10 }); - mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 })); - await store.refetch(); - }); - - afterEach(() => { - store.destroy(); - queryClient.clear(); - vi.resetAllMocks(); - }); - - it('nextPage() advances offset by limit when hasMore', () => { - store.nextPage(); - - 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(); - - store.nextPage(); - - expect(store.params.offset).toBe(10); - }); - - it('prevPage() decrements offset by limit when on page > 1', () => { - store.setParams({ offset: 10 }); - - store.prevPage(); - - expect(store.params.offset).toBe(0); - }); - - it('prevPage() does nothing on the first page', () => { - store.prevPage(); - - expect(store.params.offset).toBe(0); - }); - - it('goToPage() sets the correct offset', () => { - store.goToPage(2); - - expect(store.params.offset).toBe(10); - }); - - it('goToPage() does nothing for page 0', () => { - store.goToPage(0); - - expect(store.params.offset).toBe(0); - }); - - it('goToPage() does nothing for page beyond totalPages', () => { - store.goToPage(99); - - expect(store.params.offset).toBe(0); - }); - - it('setLimit() updates the limit param', () => { - store.setLimit(25); - - 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']); - - expect(store.params.providers).toEqual(['google']); - }); - - it('setCategories() updates the categories param', () => { - store.setCategories(['serif']); - - expect(store.params.categories).toEqual(['serif']); - }); - - it('setSubsets() updates the subsets param', () => { - store.setSubsets(['cyrillic']); - - expect(store.params.subsets).toEqual(['cyrillic']); - }); - - it('setSearch() sets the q param', () => { - store.setSearch('roboto'); - - expect(store.params.q).toBe('roboto'); - }); - - it('setSearch() with empty string sets q to undefined', () => { - store.setSearch('roboto'); - store.setSearch(''); - - expect(store.params.q).toBeUndefined(); - }); - - it('setSort() updates the sort param', () => { - store.setSort('popularity'); - - expect(store.params.sort).toBe('popularity'); - }); -}); - -describe('filter change resets pagination', () => { - let store: UnifiedFontStore; - - beforeEach(async () => { - store = new UnifiedFontStore({ limit: 10 }); - // Let the initial effect run so #previousFilterParams is set. - // Without this, the first filter change is treated as initialisation, not a reset. - await tick(); - }); - - afterEach(() => { - store.destroy(); - queryClient.clear(); - vi.resetAllMocks(); - }); - - it('resets offset to 0 when a filter changes', async () => { - store.setParams({ offset: 20 }); - - store.setSearch('roboto'); - await tick(); - - 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(); - expect(store.fonts).toHaveLength(3); - - store.setSearch('roboto'); - await tick(); - - 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('isEmpty', () => { - let store: UnifiedFontStore; - - beforeEach(() => { - store = new UnifiedFontStore({ limit: 10 }); - }); - - afterEach(() => { - store.destroy(); - queryClient.clear(); - vi.resetAllMocks(); - }); - - it('is true when fetch returns no fonts', async () => { - mockedFetch.mockResolvedValue(makeResponse([])); - await store.refetch(); - - expect(store.isEmpty).toBe(true); - }); - - it('is false when fonts are present', async () => { - mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(3))); - await store.refetch(); - - expect(store.isEmpty).toBe(false); - }); - - it('is true after an error (no fonts loaded)', async () => { - mockedFetch.mockRejectedValue(new Error('network down')); - await store.refetch().catch((e: unknown) => e); - - expect(store.isEmpty).toBe(true); - }); -}); - -describe('destroy', () => { - it('can be called without throwing', () => { - const store = new UnifiedFontStore({ limit: 10 }); - - expect(() => { - store.destroy(); - }).not.toThrow(); - }); - - it('sets filterCleanup to null so it is not called again', () => { - const store = new UnifiedFontStore({ limit: 10 }); - store.destroy(); - - // Second destroy should not throw even though filterCleanup is now null - expect(() => { - store.destroy(); - }).not.toThrow(); - }); -});