refactor: delete BaseFontStore and UnifiedFontStore — FontStore is the single implementation

This commit is contained in:
Ilia Mashkov
2026-04-08 10:07:36 +03:00
parent ed7d31bf5c
commit 45eac0c396
7 changed files with 0 additions and 1911 deletions

View File

@@ -1,117 +1,5 @@
// Proxy API (primary)
export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
} from './api/proxy/proxyFonts';
export type {
ProxyFontsParams,
ProxyFontsResponse,
} from './api/proxy/proxyFonts';
export {
normalizeFontshareFont,
normalizeFontshareFonts,
} from './lib/normalize/normalize';
export type {
// Domain types
FontCategory,
FontCollectionFilters,
FontCollectionSort,
// Store types
FontCollectionState,
FontFeatures,
FontFiles,
FontItem,
FontMetadata,
FontProvider,
// Fontshare API types
FontshareApiModel,
FontshareAxis,
FontshareDesigner,
FontshareFeature,
FontshareFont,
FontshareLink,
FontsharePublisher,
FontshareStyle,
FontshareStyleProperties,
FontshareTag,
FontshareWeight,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
// Normalization types
UnifiedFont,
UnifiedFontVariant,
} from './model';
export { export {
appliedFontsManager, appliedFontsManager,
createFontStore, createFontStore,
createUnifiedFontStore,
fontStore, fontStore,
unifiedFontStore,
} from './model'; } from './model';
// Mock data helpers for Storybook and testing
export {
createCategoriesFilter,
createErrorState,
createGenericFilter,
createLoadingState,
createMockComparisonStore,
// Filter mocks
createMockFilter,
createMockFontApiResponse,
createMockFontStoreState,
// Store mocks
createMockQueryState,
createMockReactiveState,
createMockStore,
createProvidersFilter,
createSubsetsFilter,
createSuccessState,
FONTHARE_FONTS,
generateMixedCategoryFonts,
generateMockFonts,
generatePaginatedFonts,
generateSequentialFilter,
GENERIC_FILTERS,
getAllMockFonts,
getFontsByCategory,
getFontsByProvider,
GOOGLE_FONTS,
MOCK_FILTERS,
MOCK_FILTERS_ALL_SELECTED,
MOCK_FILTERS_EMPTY,
MOCK_FILTERS_SELECTED,
MOCK_FONT_STORE_STATES,
MOCK_STORES,
type MockFilterOptions,
type MockFilters,
mockFontshareFont,
type MockFontshareFontOptions,
type MockFontStoreState,
// Font mocks
mockGoogleFont,
// Types
type MockGoogleFontOptions,
type MockQueryObserverResult,
type MockQueryState,
mockUnifiedFont,
type MockUnifiedFontOptions,
UNIFIED_FONTS,
} from './lib/mocks';
export {
FontNetworkError,
FontResponseError,
} from './lib/errors/errors';
// UI elements
export {
FontApplicator,
FontVirtualList,
} from './ui';

View File

@@ -1,47 +1,6 @@
export type {
// Domain types
FontCategory,
FontCollectionFilters,
FontCollectionSort,
// Store types
FontCollectionState,
FontFeatures,
FontFiles,
FontItem,
FontLoadRequestConfig,
FontLoadStatus,
FontMetadata,
FontProvider,
// Fontshare API types
FontshareApiModel,
FontshareAxis,
FontshareDesigner,
FontshareFeature,
FontshareFont,
FontshareLink,
FontsharePublisher,
FontshareStyle,
FontshareStyleProperties,
FontshareTag,
FontshareWeight,
FontStyleUrls,
FontSubset,
FontVariant,
FontWeight,
FontWeightItalic,
// Google Fonts API types
GoogleFontsApiModel,
// Normalization types
UnifiedFont,
UnifiedFontVariant,
} from './types';
export { export {
appliedFontsManager, appliedFontsManager,
createFontStore, createFontStore,
createUnifiedFontStore,
FontStore, FontStore,
fontStore, fontStore,
type UnifiedFontStore,
unifiedFontStore,
} from './store'; } from './store';

View File

@@ -1,644 +0,0 @@
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();
});
});
});

View File

@@ -1,206 +0,0 @@
import { queryClient } from '$shared/api/queryClient';
import {
type QueryKey,
QueryObserver,
type QueryObserverOptions,
type QueryObserverResult,
} from '@tanstack/query-core';
import type { UnifiedFont } from '../../types';
/**
* Base class for font stores using TanStack Query
*
* Provides reactive font data fetching with caching, automatic refetching,
* and parameter binding. Extended by UnifiedFontStore for provider-agnostic
* font fetching.
*
* @template TParams - Type of query parameters
*/
export abstract class BaseFontStore<TParams extends Record<string, any>> {
/**
* Cleanup function for effects
* Call destroy() to remove effects and prevent memory leaks
*/
cleanup: () => void;
/** Internal parameter state */
#internalParams = $state<TParams>({} as TParams);
/**
* Merged params from internal state
* Computed synchronously on access
*/
get params(): TParams {
// Default offset to 0 if undefined (for pagination methods)
let result = this.#internalParams as TParams;
if (result.offset === undefined) {
result = { ...result, offset: 0 } as TParams;
}
return result;
}
/** TanStack Query result state */
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
/** TanStack Query observer instance */
protected observer: QueryObserver<UnifiedFont[], Error>;
/** Shared query client */
protected qc = queryClient;
/**
* Creates a new base font store
* @param initialParams - Initial query parameters
*/
constructor(initialParams: TParams) {
this.#internalParams = initialParams;
this.observer = new QueryObserver(this.qc, this.getOptions());
// Sync TanStack Query state -> Svelte state
this.observer.subscribe(r => {
this.result = r;
});
// Sync Svelte state changes -> TanStack Query options
this.cleanup = $effect.root(() => {
$effect(() => {
this.observer.setOptions(this.getOptions());
});
});
}
/**
* Must be implemented by child class
* Returns the query key for TanStack Query caching
*/
protected abstract getQueryKey(params: TParams): QueryKey;
/**
* Must be implemented by child class
* Fetches font data from API
*/
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
/**
* Gets TanStack Query options
* @param params - Query parameters (defaults to current params)
*/
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
// Always use current params, not the captured closure params
return {
queryKey: this.getQueryKey(params),
queryFn: () => this.fetchFn(this.params),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
};
}
/** Array of fonts (empty array if loading/error) */
get fonts() {
return this.result.data ?? [];
}
/** Whether currently fetching initial data */
get isLoading() {
return this.result.isLoading;
}
/** Whether any fetch is in progress (including refetches) */
get isFetching() {
return this.result.isFetching;
}
/** Whether last fetch resulted in an error */
get isError() {
return this.result.isError;
}
/** The error from the last failed fetch, or null if no error. */
get error(): Error | null {
return this.result.error ?? null;
}
/** Whether no fonts are loaded (not loading and empty array) */
get isEmpty() {
return !this.isLoading && this.fonts.length === 0;
}
/**
* Update query parameters
* @param newParams - Partial params to merge with existing
*/
setParams(newParams: Partial<TParams>) {
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());
}
/**
* Invalidate cache and refetch
*/
invalidate() {
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
}
/**
* Clean up effects and observers
*/
destroy() {
this.cleanup();
}
/**
* Manually trigger a refetch
*/
async refetch() {
// Update options before refetching to ensure current params are used
this.observer.setOptions(this.getOptions());
await this.observer.refetch();
}
/**
* Prefetch data with different parameters
*/
async prefetch(params: TParams) {
await this.qc.prefetchQuery(this.getOptions(params));
}
/**
* Cancel ongoing queries
*/
cancel() {
this.qc.cancelQueries({
queryKey: this.getQueryKey(this.params),
});
}
/**
* Get cached data without triggering fetch
*/
getCachedData() {
return this.qc.getQueryData<UnifiedFont[]>(
this.getQueryKey(this.params),
);
}
/**
* Set data manually (optimistic updates)
*/
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
this.qc.setQueryData(
this.getQueryKey(this.params),
updater,
);
}
}

View File

@@ -6,13 +6,6 @@
* Single export point for the unified font store infrastructure. * Single export point for the unified font store infrastructure.
*/ */
// Primary store (unified)
export {
createUnifiedFontStore,
type UnifiedFontStore,
unifiedFontStore,
} from './unifiedFontStore/unifiedFontStore.svelte';
// Applied fonts manager (CSS loading - unchanged) // Applied fonts manager (CSS loading - unchanged)
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';

View File

@@ -1,474 +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('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();
});
});
});

View File

@@ -1,427 +0,0 @@
/**
* Unified font store
*
* Single source of truth for font data, powered by the proxy API.
* Extends BaseFontStore for TanStack Query integration and reactivity.
*
* Key features:
* - Provider-agnostic (proxy API handles provider logic)
* - Reactive to filter changes
* - Optimistic updates via TanStack Query
* - Pagination support
* - Provider-specific shortcuts for common operations
*/
import type { QueryObserverOptions } from '@tanstack/query-core';
import type { ProxyFontsParams } from '../../../api';
import { fetchProxyFonts } from '../../../api';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import type { UnifiedFont } from '../../types';
import { BaseFontStore } from '../baseFontStore/baseFontStore.svelte';
/**
* Unified font store wrapping TanStack Query with Svelte 5 runes
*
* Extends BaseFontStore to provide:
* - Reactive state management
* - TanStack Query integration for caching
* - Filter change tracking with pagination reset
* - Pagination support
*
* @example
* ```ts
* const store = new UnifiedFontStore({
* provider: 'google',
* category: 'sans-serif',
* limit: 50
* });
*
* // Access reactive state
* $effect(() => {
* console.log(store.fonts);
* console.log(store.isLoading);
* console.log(store.pagination);
* });
*
* // Update parameters
* store.setCategories(['serif']);
* store.nextPage();
* ```
*/
export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
/**
* Store pagination metadata separately from fonts
* This is a workaround for TanStack Query's type system
*/
#paginationMetadata = $state<
{
total: number;
limit: number;
offset: number;
} | null
>(null);
/**
* Accumulated fonts from all pages (for infinite scroll)
*/
#accumulatedFonts = $state<UnifiedFont[]>([]);
/**
* Pagination metadata (derived from proxy API response)
*/
readonly pagination = $derived.by(() => {
if (this.#paginationMetadata) {
const { total, limit, offset } = this.#paginationMetadata;
return {
total,
limit,
offset,
hasMore: offset + limit < total,
page: Math.floor(offset / limit) + 1,
totalPages: Math.ceil(total / limit),
};
}
return {
total: 0,
limit: this.params.limit || 50,
offset: this.params.offset || 0,
hasMore: false,
page: 1,
totalPages: 0,
};
});
/**
* Track previous filter params to detect changes and reset pagination
*/
#previousFilterParams = $state<string | null>(null);
/**
* Cleanup function for the filter tracking effect
*/
#filterCleanup: (() => void) | null = null;
constructor(initialParams: ProxyFontsParams = {}) {
super(initialParams);
// Track filter params (excluding pagination params)
// Wrapped in $effect.root() to prevent effect_orphan error
this.#filterCleanup = $effect.root(() => {
$effect(() => {
const filterParams = JSON.stringify({
providers: this.params.providers,
categories: this.params.categories,
subsets: this.params.subsets,
q: this.params.q,
});
// If filters changed, reset offset and invalidate cache
if (filterParams !== this.#previousFilterParams) {
if (this.#previousFilterParams) {
if (this.params.offset !== 0) {
this.setParams({ offset: 0 });
}
this.#accumulatedFonts = [];
this.invalidate();
}
this.#previousFilterParams = filterParams;
}
});
// Effect: Sync state from Query result (Handles Cache Hits)
$effect(() => {
const data = this.result.data;
const offset = this.params.offset ?? 0;
// When we have data and we are at the start (offset 0),
// we must ensure accumulatedFonts matches the fresh (or cached) data.
// This fixes the issue where cache hits skip fetchFn side-effects.
// Only sync at offset 0 to avoid clearing fonts during cache hits at other offsets.
if (offset === 0 && data && data.length > 0) {
this.#accumulatedFonts = data;
}
});
});
}
/**
* Clean up both parent and child effects
*/
destroy() {
// Call parent cleanup (TanStack observer effect)
super.destroy();
// Call filter tracking effect cleanup
if (this.#filterCleanup) {
this.#filterCleanup();
this.#filterCleanup = null;
}
}
/**
* Query key for TanStack Query caching
* Normalizes params to treat empty arrays/strings as undefined
*/
protected getQueryKey(params: ProxyFontsParams) {
// Normalize params to treat empty arrays/strings as undefined
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) {
return acc;
}
return { ...acc, [key]: value };
}, {});
// Return a consistent key
return ['unifiedFonts', normalized] as const;
}
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
const hasFilters = !!(params.q || params.providers || params.categories || params.subsets);
return {
queryKey: this.getQueryKey(params),
queryFn: () => this.fetchFn(params),
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
};
}
/**
* Fetch function that calls the proxy API
* Returns the full response including pagination metadata
*/
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
let response: Awaited<ReturnType<typeof fetchProxyFonts>>;
try {
response = await fetchProxyFonts(params);
} catch (cause) {
throw new FontNetworkError(cause);
}
if (!response) {
throw new FontResponseError('response', response);
}
if (!response.fonts) {
throw new FontResponseError('response.fonts', response.fonts);
}
if (!Array.isArray(response.fonts)) {
throw new FontResponseError('response.fonts', response.fonts);
}
this.#paginationMetadata = {
total: response.total ?? 0,
limit: response.limit ?? this.params.limit ?? 50,
offset: response.offset ?? this.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];
}
return response.fonts;
}
/**
* Get all accumulated fonts (for infinite scroll)
*/
get fonts(): UnifiedFont[] {
return this.#accumulatedFonts;
}
/**
* Check if loading initial data
*/
get isLoading(): boolean {
return this.result.isLoading;
}
/**
* Check if fetching (including background refetches)
*/
get isFetching(): boolean {
return this.result.isFetching;
}
/**
* Check if error occurred
*/
get isError(): boolean {
return this.result.isError;
}
/**
* Check if result is empty (not loading and no fonts)
*/
get isEmpty(): boolean {
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
*/
setProviders(providers: ProxyFontsParams['providers']) {
this.setParams({ providers });
}
/**
* Set categories filter
*/
setCategories(categories: ProxyFontsParams['categories']) {
this.setParams({ categories });
}
/**
* Set subsets filter
*/
setSubsets(subsets: ProxyFontsParams['subsets']) {
this.setParams({ subsets });
}
/**
* Set search query
*/
setSearch(search: string) {
this.setParams({ q: search || undefined });
}
/**
* Set sort order
*/
setSort(sort: ProxyFontsParams['sort']) {
this.setParams({ sort });
}
/**
* Go to next page
*/
nextPage() {
if (this.pagination.hasMore) {
this.setParams({
offset: this.pagination.offset + this.pagination.limit,
});
}
}
/**
* Go to previous page
*/
prevPage() {
if (this.pagination.page > 1) {
this.setParams({
offset: this.pagination.offset - this.pagination.limit,
});
}
}
/**
* Go to specific page
*/
goToPage(page: number) {
if (page >= 1 && page <= this.pagination.totalPages) {
this.setParams({
offset: (page - 1) * this.pagination.limit,
});
}
}
/**
* Set limit (items per page)
*/
setLimit(limit: number) {
this.setParams({ limit });
}
get sansSerifFonts() {
return this.fonts.filter(f => f.category === 'sans-serif');
}
get serifFonts() {
return this.fonts.filter(f => f.category === 'serif');
}
get displayFonts() {
return this.fonts.filter(f => f.category === 'display');
}
get handwritingFonts() {
return this.fonts.filter(f => f.category === 'handwriting');
}
get monospaceFonts() {
return this.fonts.filter(f => f.category === 'monospace');
}
}
/**
* Factory function to create unified font store
*/
export function createUnifiedFontStore(params: ProxyFontsParams = {}) {
return new UnifiedFontStore(params);
}
/**
* Singleton instance for global use
* Initialized with a default limit to prevent fetching all fonts at once
*/
export const unifiedFontStore = new UnifiedFontStore({
limit: 50,
offset: 0,
});