feat/font-store-merge #32
592
src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts
Normal file
592
src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
import {
|
||||||
|
type InfiniteData,
|
||||||
|
QueryClient,
|
||||||
|
} from '@tanstack/query-core';
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '../../../lib/errors/errors';
|
||||||
|
import {
|
||||||
|
generateMixedCategoryFonts,
|
||||||
|
generateMockFonts,
|
||||||
|
} from '../../../lib/mocks/fonts.mock';
|
||||||
|
import type { UnifiedFont } from '../../types';
|
||||||
|
import { FontStore } from './fontStore.svelte';
|
||||||
|
|
||||||
|
vi.mock('$shared/api/queryClient', () => ({
|
||||||
|
queryClient: new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||||
|
|
||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import { fetchProxyFonts } from '../../../api';
|
||||||
|
|
||||||
|
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
||||||
|
|
||||||
|
const makeResponse = (
|
||||||
|
fonts: UnifiedFont[],
|
||||||
|
meta: { total?: number; limit?: number; offset?: number } = {},
|
||||||
|
): FontPage => ({
|
||||||
|
fonts,
|
||||||
|
total: meta.total ?? fonts.length,
|
||||||
|
limit: meta.limit ?? 10,
|
||||||
|
offset: meta.offset ?? 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeStore(params = {}) {
|
||||||
|
return new FontStore({ limit: 10, ...params });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
|
||||||
|
const store = makeStore(params);
|
||||||
|
fetch.mockResolvedValue(makeResponse(fonts, meta));
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FontStore', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('construction', () => {
|
||||||
|
it('stores initial params', () => {
|
||||||
|
const store = makeStore({ limit: 20 });
|
||||||
|
expect(store.params.limit).toBe(20);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults limit to 50 when not provided', () => {
|
||||||
|
const store = new FontStore();
|
||||||
|
expect(store.params.limit).toBe(50);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with empty fonts', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect(store.fonts).toEqual([]);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with isEmpty false — initial fetch is in progress', () => {
|
||||||
|
// The observer starts fetching immediately on construction.
|
||||||
|
// isEmpty must be false so the UI shows a loader, not "no results".
|
||||||
|
const store = makeStore();
|
||||||
|
expect(store.isEmpty).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('state after fetch', () => {
|
||||||
|
it('exposes loaded fonts', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(7));
|
||||||
|
expect(store.fonts).toHaveLength(7);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isEmpty is false when fonts are present', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.isEmpty).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isLoading is false after fetch', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.isLoading).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isFetching is false after fetch', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.isFetching).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isError is false on success', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error is null on success', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.error).toBeNull();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('error states', () => {
|
||||||
|
it('isError is false before any fetch', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps network failures in FontNetworkError', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockRejectedValue(new Error('network down'));
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
expect(store.error).toBeInstanceOf(FontNetworkError);
|
||||||
|
expect(store.isError).toBe(true);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes FontResponseError for falsy response', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(null);
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
expect((store.error as FontResponseError).field).toBe('response');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes FontResponseError for missing fonts field', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 });
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
expect((store.error as FontResponseError).field).toBe('response.fonts');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes FontResponseError for non-array fonts', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 });
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
expect((store.error as FontResponseError).received).toBe('bad');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('font accumulation', () => {
|
||||||
|
it('replaces fonts when refetching the first page', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(3)));
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const second = generateMockFonts(2);
|
||||||
|
fetch.mockResolvedValue(makeResponse(second));
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
// refetch at offset=0 re-fetches all pages; only one page loaded → new data replaces old
|
||||||
|
expect(store.fonts).toHaveLength(2);
|
||||||
|
expect(store.fonts[0].id).toBe(second[0].id);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends fonts after nextPage', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
const page1 = generateMockFonts(3);
|
||||||
|
fetch.mockResolvedValue(makeResponse(page1, { total: 6, limit: 3, offset: 0 }));
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const page2 = generateMockFonts(3).map((f, i) => ({ ...f, id: `p2-${i}` }));
|
||||||
|
fetch.mockResolvedValue(makeResponse(page2, { total: 6, limit: 3, offset: 3 }));
|
||||||
|
await store.nextPage();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(6);
|
||||||
|
expect(store.fonts.slice(0, 3).map(f => f.id)).toEqual(page1.map(f => f.id));
|
||||||
|
expect(store.fonts.slice(3).map(f => f.id)).toEqual(page2.map(f => f.id));
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('pagination state', () => {
|
||||||
|
it('returns zero-value defaults before any fetch', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect(store.pagination).toMatchObject({ total: 0, hasMore: false, page: 1, totalPages: 0 });
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects response metadata after fetch', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
|
||||||
|
expect(store.pagination.total).toBe(30);
|
||||||
|
expect(store.pagination.hasMore).toBe(true);
|
||||||
|
expect(store.pagination.page).toBe(1);
|
||||||
|
expect(store.pagination.totalPages).toBe(3);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasMore is false on the last page', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(10), { total: 10, limit: 10, offset: 0 });
|
||||||
|
expect(store.pagination.hasMore).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('page count increments after nextPage', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
expect(store.pagination.page).toBe(1);
|
||||||
|
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
|
||||||
|
await store.nextPage();
|
||||||
|
flushSync();
|
||||||
|
expect(store.pagination.page).toBe(2);
|
||||||
|
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('setParams', () => {
|
||||||
|
it('merges updates into existing params', () => {
|
||||||
|
const store = makeStore({ limit: 10 });
|
||||||
|
store.setParams({ limit: 20 });
|
||||||
|
expect(store.params.limit).toBe(20);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains unmodified params', () => {
|
||||||
|
const store = makeStore({ limit: 10 });
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
store.setParams({ limit: 25 });
|
||||||
|
expect(store.params.categories).toEqual(['serif']);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('filter change resets', () => {
|
||||||
|
it('clears accumulated fonts when a filter changes', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
store.setSearch('roboto');
|
||||||
|
flushSync();
|
||||||
|
// TQ switches to a new queryKey → data.pages reset → fonts = []
|
||||||
|
expect(store.fonts).toHaveLength(0);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isEmpty is false immediately after filter change — fetch is in progress', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
// Hang the next fetch so we can observe the transitioning state
|
||||||
|
fetch.mockReturnValue(new Promise(() => {}));
|
||||||
|
store.setSearch('roboto');
|
||||||
|
flushSync();
|
||||||
|
// fonts = [] AND isFetching = true → isEmpty must be false (no "no results" flash)
|
||||||
|
expect(store.isEmpty).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT reset fonts when the same filter value is set again', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
flushSync();
|
||||||
|
// First change: clears fonts (expected)
|
||||||
|
store.setCategories(['serif']); // same value — same queryKey — TQ keeps data.pages
|
||||||
|
flushSync();
|
||||||
|
// Because queryKey hasn't changed, TQ returns cached data — fonts restored from cache
|
||||||
|
// (actual font count depends on cache; key assertion is no extra reset)
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('staleTime in buildOptions', () => {
|
||||||
|
it('is 5 minutes with no active filters', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect((store as any).buildOptions().staleTime).toBe(5 * 60 * 1000);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 when a search query is active', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setSearch('roboto');
|
||||||
|
expect((store as any).buildOptions().staleTime).toBe(0);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 when a category filter is active', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
expect((store as any).buildOptions().staleTime).toBe(0);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gcTime is 10 minutes always', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect((store as any).buildOptions().gcTime).toBe(10 * 60 * 1000);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('buildQueryKey', () => {
|
||||||
|
it('omits empty-string params', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setSearch('');
|
||||||
|
const [root, normalized] = (store as any).buildQueryKey(store.params);
|
||||||
|
expect(root).toBe('fonts');
|
||||||
|
expect(normalized).not.toHaveProperty('q');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits empty-array params', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setProviders([]);
|
||||||
|
const [, normalized] = (store as any).buildQueryKey(store.params);
|
||||||
|
expect(normalized).not.toHaveProperty('providers');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes non-empty filter values', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
const [, normalized] = (store as any).buildQueryKey(store.params);
|
||||||
|
expect(normalized).toHaveProperty('categories', ['serif']);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include offset (offset is the TQ page param, not a query key component)', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
const [, normalized] = (store as any).buildQueryKey(store.params);
|
||||||
|
expect(normalized).not.toHaveProperty('offset');
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('destroy', () => {
|
||||||
|
it('does not throw', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
expect(() => store.destroy()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
store.destroy();
|
||||||
|
expect(() => store.destroy()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('refetch', () => {
|
||||||
|
it('triggers a fetch', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(3)));
|
||||||
|
await store.refetch();
|
||||||
|
expect(fetch).toHaveBeenCalled();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses params current at call time', async () => {
|
||||||
|
const store = makeStore({ limit: 10 });
|
||||||
|
store.setParams({ limit: 20 });
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(20)));
|
||||||
|
await store.refetch();
|
||||||
|
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 }));
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('nextPage', () => {
|
||||||
|
let store: FontStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
||||||
|
store = new FontStore({ limit: 10 });
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the next page and appends fonts', async () => {
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
|
||||||
|
await store.nextPage();
|
||||||
|
flushSync();
|
||||||
|
expect(store.fonts).toHaveLength(20);
|
||||||
|
expect(store.pagination.offset).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when hasMore is false', async () => {
|
||||||
|
// Set up a store where all fonts fit in one page (hasMore = false)
|
||||||
|
queryClient.clear();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
|
||||||
|
store = new FontStore({ limit: 10 });
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.pagination.hasMore).toBe(false);
|
||||||
|
await store.nextPage(); // should not trigger another fetch
|
||||||
|
expect(store.fonts).toHaveLength(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('prevPage and goToPage', () => {
|
||||||
|
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
store.prevPage();
|
||||||
|
expect(store.fonts).toHaveLength(5); // unchanged
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goToPage is a no-op — infinite scroll does not support arbitrary page jumps', async () => {
|
||||||
|
const store = await fetchedStore({}, generateMockFonts(5));
|
||||||
|
store.goToPage(3);
|
||||||
|
expect(store.fonts).toHaveLength(5); // unchanged
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('prefetch', () => {
|
||||||
|
it('triggers a fetch for the provided params', async () => {
|
||||||
|
const store = makeStore();
|
||||||
|
fetch.mockResolvedValue(makeResponse(generateMockFonts(5)));
|
||||||
|
await store.prefetch({ limit: 5 });
|
||||||
|
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 5, offset: 0 }));
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('getCachedData / setQueryData', () => {
|
||||||
|
it('getCachedData returns undefined before any fetch', () => {
|
||||||
|
queryClient.clear();
|
||||||
|
const store = new FontStore({ limit: 10 });
|
||||||
|
expect(store.getCachedData()).toBeUndefined();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCachedData returns flattened fonts after fetch', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
expect(store.getCachedData()).toHaveLength(5);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setQueryData writes to cache', () => {
|
||||||
|
const store = makeStore();
|
||||||
|
const font = generateMockFonts(1)[0];
|
||||||
|
store.setQueryData(() => [font]);
|
||||||
|
expect(store.getCachedData()).toHaveLength(1);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setQueryData updater receives existing flattened fonts', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
const updater = vi.fn((old: UnifiedFont[] | undefined) => old ?? []);
|
||||||
|
store.setQueryData(updater);
|
||||||
|
expect(updater).toHaveBeenCalledWith(expect.any(Array));
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('invalidate', () => {
|
||||||
|
it('calls invalidateQueries', async () => {
|
||||||
|
const store = await fetchedStore();
|
||||||
|
const spy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||||
|
store.invalidate();
|
||||||
|
expect(spy).toHaveBeenCalledOnce();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('setLimit', () => {
|
||||||
|
it('updates the limit param', () => {
|
||||||
|
const store = makeStore({ limit: 10 });
|
||||||
|
store.setLimit(25);
|
||||||
|
expect(store.params.limit).toBe(25);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('filter shortcut methods', () => {
|
||||||
|
let store: FontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = makeStore();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setProviders updates providers param', () => {
|
||||||
|
store.setProviders(['google']);
|
||||||
|
expect(store.params.providers).toEqual(['google']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setCategories updates categories param', () => {
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
expect(store.params.categories).toEqual(['serif']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSubsets updates subsets param', () => {
|
||||||
|
store.setSubsets(['cyrillic']);
|
||||||
|
expect(store.params.subsets).toEqual(['cyrillic']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSearch sets q param', () => {
|
||||||
|
store.setSearch('roboto');
|
||||||
|
expect(store.params.q).toBe('roboto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSearch with empty string clears q', () => {
|
||||||
|
store.setSearch('roboto');
|
||||||
|
store.setSearch('');
|
||||||
|
expect(store.params.q).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSort updates sort param', () => {
|
||||||
|
store.setSort('popularity');
|
||||||
|
expect(store.params.sort).toBe('popularity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe('category getters', () => {
|
||||||
|
it('each getter returns only fonts of that category', async () => {
|
||||||
|
const store = makeStore({ limit: 50 });
|
||||||
|
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
|
||||||
|
fetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
expect(store.sansSerifFonts.every(f => f.category === 'sans-serif')).toBe(true);
|
||||||
|
expect(store.serifFonts.every(f => f.category === 'serif')).toBe(true);
|
||||||
|
expect(store.displayFonts.every(f => f.category === 'display')).toBe(true);
|
||||||
|
expect(store.handwritingFonts.every(f => f.category === 'handwriting')).toBe(true);
|
||||||
|
expect(store.monospaceFonts.every(f => f.category === 'monospace')).toBe(true);
|
||||||
|
expect(store.sansSerifFonts).toHaveLength(2);
|
||||||
|
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
149
src/entities/Font/model/store/fontStore/fontStore.svelte.ts
Normal file
149
src/entities/Font/model/store/fontStore/fontStore.svelte.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import {
|
||||||
|
type InfiniteData,
|
||||||
|
InfiniteQueryObserver,
|
||||||
|
type InfiniteQueryObserverResult,
|
||||||
|
type QueryFunctionContext,
|
||||||
|
} from '@tanstack/query-core';
|
||||||
|
import type { ProxyFontsParams } from '../../../api';
|
||||||
|
import type { UnifiedFont } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of a single page as returned by the proxy API and stored by TQ.
|
||||||
|
* Each entry in `result.data.pages` is a `FontPage`.
|
||||||
|
*/
|
||||||
|
type FontPage = {
|
||||||
|
fonts: UnifiedFont[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PageParam = { offset: number };
|
||||||
|
|
||||||
|
/** Filter params + limit — offset is managed by TQ as a page param, not a user param. */
|
||||||
|
type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
|
||||||
|
|
||||||
|
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<FontPage, PageParam>, Error>;
|
||||||
|
|
||||||
|
export class FontStore {
|
||||||
|
#params = $state<FontStoreParams>({ limit: 50 });
|
||||||
|
#result = $state<FontStoreResult>({} as FontStoreResult);
|
||||||
|
#observer: InfiniteQueryObserver<FontPage, Error, InfiniteData<FontPage, PageParam>, readonly unknown[], PageParam>;
|
||||||
|
#qc = queryClient;
|
||||||
|
#unsubscribe: () => void;
|
||||||
|
|
||||||
|
constructor(params: FontStoreParams = {}) {
|
||||||
|
this.#params = { limit: 50, ...params };
|
||||||
|
this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions());
|
||||||
|
this.#unsubscribe = this.#observer.subscribe(r => {
|
||||||
|
this.#result = r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Public state --
|
||||||
|
|
||||||
|
get params(): FontStoreParams {
|
||||||
|
return this.#params;
|
||||||
|
}
|
||||||
|
get fonts(): UnifiedFont[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
get isLoading(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
get isFetching(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
get isError(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
get error(): Error | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
get isEmpty(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get pagination() {
|
||||||
|
return { total: 0, limit: 50, offset: 0, hasMore: false, page: 1, totalPages: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Lifecycle --
|
||||||
|
|
||||||
|
destroy(): void {}
|
||||||
|
|
||||||
|
// -- Param management --
|
||||||
|
|
||||||
|
setParams(_updates: Partial<FontStoreParams>): void {}
|
||||||
|
invalidate(): void {}
|
||||||
|
|
||||||
|
// -- Async operations --
|
||||||
|
|
||||||
|
async refetch(): Promise<void> {}
|
||||||
|
async prefetch(_params: FontStoreParams): Promise<void> {}
|
||||||
|
cancel(): void {}
|
||||||
|
getCachedData(): UnifiedFont[] | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
setQueryData(_updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]): void {}
|
||||||
|
|
||||||
|
// -- Filter shortcuts --
|
||||||
|
|
||||||
|
setProviders(_v: ProxyFontsParams['providers']): void {}
|
||||||
|
setCategories(_v: ProxyFontsParams['categories']): void {}
|
||||||
|
setSubsets(_v: ProxyFontsParams['subsets']): void {}
|
||||||
|
setSearch(_v: string): void {}
|
||||||
|
setSort(_v: ProxyFontsParams['sort']): void {}
|
||||||
|
|
||||||
|
// -- Pagination navigation --
|
||||||
|
|
||||||
|
async nextPage(): Promise<void> {}
|
||||||
|
prevPage(): void {} // no-op: infinite scroll only loads forward
|
||||||
|
goToPage(_page: number): void {} // no-op
|
||||||
|
setLimit(_limit: number): void {}
|
||||||
|
|
||||||
|
// -- Category views --
|
||||||
|
|
||||||
|
get sansSerifFonts(): UnifiedFont[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
get serifFonts(): UnifiedFont[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
get displayFonts(): UnifiedFont[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
get handwritingFonts(): UnifiedFont[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
get monospaceFonts(): UnifiedFont[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Private helpers (TypeScript-private so tests can spy via `as any`) --
|
||||||
|
|
||||||
|
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
|
||||||
|
return ['fonts', params];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildOptions(params = this.#params) {
|
||||||
|
return {
|
||||||
|
queryKey: this.buildQueryKey(params),
|
||||||
|
queryFn: ({ pageParam }: QueryFunctionContext<readonly unknown[], PageParam>) =>
|
||||||
|
this.fetchPage({ ...this.#params, ...pageParam }),
|
||||||
|
initialPageParam: { offset: 0 } as PageParam,
|
||||||
|
getNextPageParam: (_lastPage: FontPage): PageParam | undefined => undefined,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchPage(_params: ProxyFontsParams): Promise<FontPage> {
|
||||||
|
return { fonts: [], total: 0, limit: 0, offset: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFontStore(params: FontStoreParams = {}): FontStore {
|
||||||
|
return new FontStore(params);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user