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();
- });
-});