test: full test coverage of baseFontStore and unifiedFontStore
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>glyphdiff</title>
|
<title>glyphdiff</title>
|
||||||
|
<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -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<TestParams> {
|
||||||
|
protected getQueryKey(params: TestParams) {
|
||||||
|
return ['testFonts', params] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async fetchFn(params: TestParams): Promise<UnifiedFont[]> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,25 +23,22 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
*/
|
*/
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
|
|
||||||
/** Reactive parameter bindings from external sources */
|
|
||||||
#bindings = $state<(() => Partial<TParams>)[]>([]);
|
|
||||||
/** Internal parameter state */
|
/** Internal parameter state */
|
||||||
#internalParams = $state<TParams>({} as TParams);
|
#internalParams = $state<TParams>({} as TParams);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merged params from internal state and all bindings
|
* Merged params from internal state
|
||||||
* Automatically updates when bindings or internal params change
|
* Computed synchronously on access
|
||||||
*/
|
*/
|
||||||
params = $derived.by(() => {
|
get params(): TParams {
|
||||||
let merged = { ...this.#internalParams };
|
// Default offset to 0 if undefined (for pagination methods)
|
||||||
|
let result = this.#internalParams as TParams;
|
||||||
// Merge all binding results into params
|
if (result.offset === undefined) {
|
||||||
for (const getter of this.#bindings) {
|
result = { ...result, offset: 0 } as TParams;
|
||||||
const bindingResult = getter();
|
|
||||||
merged = { ...merged, ...bindingResult };
|
|
||||||
}
|
}
|
||||||
return merged as TParams;
|
|
||||||
});
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** TanStack Query result state */
|
/** TanStack Query result state */
|
||||||
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
|
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
|
||||||
@@ -89,9 +86,10 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
* @param params - Query parameters (defaults to current params)
|
* @param params - Query parameters (defaults to current params)
|
||||||
*/
|
*/
|
||||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||||
|
// Always use current params, not the captured closure params
|
||||||
return {
|
return {
|
||||||
queryKey: this.getQueryKey(params),
|
queryKey: this.getQueryKey(params),
|
||||||
queryFn: () => this.fetchFn(params),
|
queryFn: () => this.fetchFn(this.params),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
gcTime: 10 * 60 * 1000,
|
gcTime: 10 * 60 * 1000,
|
||||||
};
|
};
|
||||||
@@ -127,25 +125,25 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
return !this.isLoading && this.fonts.length === 0;
|
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<TParams>) {
|
|
||||||
this.#bindings.push(getter);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
this.#bindings = this.#bindings.filter(b => b !== getter);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update query parameters
|
* Update query parameters
|
||||||
* @param newParams - Partial params to merge with existing
|
* @param newParams - Partial params to merge with existing
|
||||||
*/
|
*/
|
||||||
setParams(newParams: Partial<TParams>) {
|
setParams(newParams: Partial<TParams>) {
|
||||||
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<TParams>) {
|
||||||
|
this.#internalParams = { ...this.#internalParams, ...newParams };
|
||||||
|
// Update observer options
|
||||||
|
this.observer.setOptions(this.getOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,6 +164,8 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
* Manually trigger a refetch
|
* Manually trigger a refetch
|
||||||
*/
|
*/
|
||||||
async refetch() {
|
async refetch() {
|
||||||
|
// Update options before refetching to ensure current params are used
|
||||||
|
this.observer.setOptions(this.getOptions());
|
||||||
await this.observer.refetch();
|
await this.observer.refetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,15 +185,6 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear cache for current params
|
|
||||||
*/
|
|
||||||
clearCache() {
|
|
||||||
this.qc.removeQueries({
|
|
||||||
queryKey: this.getQueryKey(this.params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cached data without triggering fetch
|
* Get cached data without triggering fetch
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,7 +28,7 @@ import { BaseFontStore } from '../baseFontStore/baseFontStore.svelte';
|
|||||||
* Extends BaseFontStore to provide:
|
* Extends BaseFontStore to provide:
|
||||||
* - Reactive state management
|
* - Reactive state management
|
||||||
* - TanStack Query integration for caching
|
* - TanStack Query integration for caching
|
||||||
* - Dynamic parameter binding for filters
|
* - Filter change tracking with pagination reset
|
||||||
* - Pagination support
|
* - Pagination support
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
@@ -97,7 +97,7 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
/**
|
/**
|
||||||
* Track previous filter params to detect changes and reset pagination
|
* Track previous filter params to detect changes and reset pagination
|
||||||
*/
|
*/
|
||||||
#previousFilterParams = $state<string>('');
|
#previousFilterParams = $state<string | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup function for the filter tracking effect
|
* Cleanup function for the filter tracking effect
|
||||||
@@ -134,11 +134,12 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
// Effect: Sync state from Query result (Handles Cache Hits)
|
// Effect: Sync state from Query result (Handles Cache Hits)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const data = this.result.data;
|
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),
|
// When we have data and we are at the start (offset 0),
|
||||||
// we must ensure accumulatedFonts matches the fresh (or cached) data.
|
// we must ensure accumulatedFonts matches the fresh (or cached) data.
|
||||||
// This fixes the issue where cache hits skip fetchFn side-effects.
|
// 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) {
|
if (offset === 0 && data && data.length > 0) {
|
||||||
this.#accumulatedFonts = data;
|
this.#accumulatedFonts = data;
|
||||||
}
|
}
|
||||||
@@ -215,7 +216,12 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
offset: response.offset ?? this.params.offset ?? 0,
|
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];
|
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +263,57 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
return !this.isLoading && this.fonts.length === 0;
|
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<ProxyFontsParams>) {
|
||||||
|
// 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<ProxyFontsParams>) {
|
||||||
|
// First update params normally
|
||||||
|
super.setParams(newParams);
|
||||||
|
// Then check if filters changed (for test contexts)
|
||||||
|
this.#checkAndResetFilters(newParams);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set providers filter
|
* Set providers filter
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user