Compare commits
11 Commits
4281d94d66
...
d70fc9f918
| Author | SHA1 | Date | |
|---|---|---|---|
| d70fc9f918 | |||
|
|
14dbd374ec | ||
|
|
dc6e15492a | ||
|
|
45eac0c396 | ||
|
|
ed7d31bf5c | ||
|
|
468d2e7f8c | ||
|
|
2a761b9d47 | ||
|
|
a9e4633b64 | ||
|
|
778988977f | ||
|
|
9a9ff95bf3 | ||
|
|
7517678e87 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ node_modules
|
|||||||
/build
|
/build
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
|
# Git worktrees (isolated development branches)
|
||||||
|
.worktrees
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -1,115 +1,3 @@
|
|||||||
// Proxy API (primary)
|
export * from './api';
|
||||||
export {
|
export * from './model';
|
||||||
fetchFontsByIds,
|
export * from './ui';
|
||||||
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 {
|
|
||||||
appliedFontsManager,
|
|
||||||
createUnifiedFontStore,
|
|
||||||
unifiedFontStore,
|
|
||||||
} 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';
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
|
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
|
||||||
*
|
*
|
||||||
* // Use preset stores
|
* // Use preset stores
|
||||||
* const mockFontStore = MOCK_STORES.unifiedFontStore();
|
* const mockFontStore = createMockFontStore();
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -459,6 +459,117 @@ export const MOCK_STORES = {
|
|||||||
resetFilters: () => {},
|
resetFilters: () => {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Create a mock FontStore object
|
||||||
|
* Matches FontStore's public API for Storybook use
|
||||||
|
*/
|
||||||
|
fontStore: (config: {
|
||||||
|
fonts?: UnifiedFont[];
|
||||||
|
total?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
error?: Error | null;
|
||||||
|
hasMore?: boolean;
|
||||||
|
page?: number;
|
||||||
|
} = {}) => {
|
||||||
|
const {
|
||||||
|
fonts: mockFonts = Object.values(UNIFIED_FONTS).slice(0, 5),
|
||||||
|
total: mockTotal = mockFonts.length,
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
isLoading = false,
|
||||||
|
isFetching = false,
|
||||||
|
isError = false,
|
||||||
|
error = null,
|
||||||
|
hasMore = false,
|
||||||
|
page = 1,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(mockTotal / limit);
|
||||||
|
const state = {
|
||||||
|
params: { limit },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State getters
|
||||||
|
get params() {
|
||||||
|
return state.params;
|
||||||
|
},
|
||||||
|
get fonts() {
|
||||||
|
return mockFonts;
|
||||||
|
},
|
||||||
|
get isLoading() {
|
||||||
|
return isLoading;
|
||||||
|
},
|
||||||
|
get isFetching() {
|
||||||
|
return isFetching;
|
||||||
|
},
|
||||||
|
get isError() {
|
||||||
|
return isError;
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
get isEmpty() {
|
||||||
|
return !isLoading && !isFetching && mockFonts.length === 0;
|
||||||
|
},
|
||||||
|
get pagination() {
|
||||||
|
return {
|
||||||
|
total: mockTotal,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// Category getters
|
||||||
|
get sansSerifFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'sans-serif');
|
||||||
|
},
|
||||||
|
get serifFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'serif');
|
||||||
|
},
|
||||||
|
get displayFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'display');
|
||||||
|
},
|
||||||
|
get handwritingFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'handwriting');
|
||||||
|
},
|
||||||
|
get monospaceFonts() {
|
||||||
|
return mockFonts.filter(f => f.category === 'monospace');
|
||||||
|
},
|
||||||
|
// Lifecycle
|
||||||
|
destroy() {},
|
||||||
|
// Param management
|
||||||
|
setParams(_updates: Record<string, unknown>) {},
|
||||||
|
invalidate() {},
|
||||||
|
// Async operations (no-op for Storybook)
|
||||||
|
refetch() {},
|
||||||
|
prefetch() {},
|
||||||
|
cancel() {},
|
||||||
|
getCachedData() {
|
||||||
|
return mockFonts.length > 0 ? mockFonts : undefined;
|
||||||
|
},
|
||||||
|
setQueryData() {},
|
||||||
|
// Filter shortcuts
|
||||||
|
setProviders() {},
|
||||||
|
setCategories() {},
|
||||||
|
setSubsets() {},
|
||||||
|
setSearch() {},
|
||||||
|
setSort() {},
|
||||||
|
// Pagination navigation
|
||||||
|
nextPage() {},
|
||||||
|
prevPage() {},
|
||||||
|
goToPage() {},
|
||||||
|
setLimit(_limit: number) {
|
||||||
|
state.params.limit = _limit;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// REACTIVE STATE MOCKS
|
// REACTIVE STATE MOCKS
|
||||||
|
|||||||
@@ -1,44 +1,7 @@
|
|||||||
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,
|
||||||
createUnifiedFontStore,
|
createFontStore,
|
||||||
type UnifiedFontStore,
|
FontStore,
|
||||||
unifiedFontStore,
|
fontStore,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
export * from './types';
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
583
src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts
Normal file
583
src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
import { 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] = {}) {
|
||||||
|
fetch.mockResolvedValue(makeResponse(fonts, meta));
|
||||||
|
const store = makeStore(params);
|
||||||
|
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 () => {
|
||||||
|
fetch.mockRejectedValue(new Error('network down'));
|
||||||
|
const store = makeStore();
|
||||||
|
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 () => {
|
||||||
|
fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 });
|
||||||
|
const store = makeStore();
|
||||||
|
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 () => {
|
||||||
|
fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 });
|
||||||
|
const store = makeStore();
|
||||||
|
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 page1 = generateMockFonts(3);
|
||||||
|
const store = await fetchedStore({ limit: 3 }, page1, { total: 6, limit: 3, offset: 0 });
|
||||||
|
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 = await fetchedStore({ limit: 10 }, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
|
||||||
|
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 fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
|
||||||
|
fetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
||||||
|
const store = makeStore({ limit: 50 });
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
283
src/entities/Font/model/store/fontStore/fontStore.svelte.ts
Normal file
283
src/entities/Font/model/store/fontStore/fontStore.svelte.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import {
|
||||||
|
type InfiniteData,
|
||||||
|
InfiniteQueryObserver,
|
||||||
|
type InfiniteQueryObserverResult,
|
||||||
|
type QueryFunctionContext,
|
||||||
|
} from '@tanstack/query-core';
|
||||||
|
import {
|
||||||
|
type ProxyFontsParams,
|
||||||
|
type ProxyFontsResponse,
|
||||||
|
fetchProxyFonts,
|
||||||
|
} from '../../../api';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '../../../lib/errors/errors';
|
||||||
|
import type { UnifiedFont } from '../../types';
|
||||||
|
|
||||||
|
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<ProxyFontsResponse, PageParam>, Error>;
|
||||||
|
|
||||||
|
export class FontStore {
|
||||||
|
#params = $state<FontStoreParams>({ limit: 50 });
|
||||||
|
#result = $state<FontStoreResult>({} as FontStoreResult);
|
||||||
|
#observer: InfiniteQueryObserver<
|
||||||
|
ProxyFontsResponse,
|
||||||
|
Error,
|
||||||
|
InfiniteData<ProxyFontsResponse, 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 this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
|
||||||
|
}
|
||||||
|
get isLoading(): boolean {
|
||||||
|
return this.#result.isLoading;
|
||||||
|
}
|
||||||
|
get isFetching(): boolean {
|
||||||
|
return this.#result.isFetching;
|
||||||
|
}
|
||||||
|
get isError(): boolean {
|
||||||
|
return this.#result.isError;
|
||||||
|
}
|
||||||
|
|
||||||
|
get error(): Error | null {
|
||||||
|
return this.#result.error ?? null;
|
||||||
|
}
|
||||||
|
// isEmpty is false during loading/fetching so the UI never flashes "no results"
|
||||||
|
// while a fetch is in progress. The !isFetching guard is specifically for the filter-change
|
||||||
|
// transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false.
|
||||||
|
get isEmpty(): boolean {
|
||||||
|
return !this.isLoading && !this.isFetching && this.fonts.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get pagination() {
|
||||||
|
const pages = this.#result.data?.pages;
|
||||||
|
const last = pages?.at(-1);
|
||||||
|
if (!last) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
limit: this.#params.limit ?? 50,
|
||||||
|
offset: 0,
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total: last.total,
|
||||||
|
limit: last.limit,
|
||||||
|
offset: last.offset,
|
||||||
|
hasMore: this.#result.hasNextPage,
|
||||||
|
page: pages!.length,
|
||||||
|
totalPages: Math.ceil(last.total / last.limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Lifecycle --
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.#unsubscribe();
|
||||||
|
this.#observer.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Param management --
|
||||||
|
|
||||||
|
setParams(updates: Partial<FontStoreParams>) {
|
||||||
|
this.#params = { ...this.#params, ...updates };
|
||||||
|
this.#observer.setOptions(this.buildOptions());
|
||||||
|
}
|
||||||
|
invalidate() {
|
||||||
|
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Async operations --
|
||||||
|
|
||||||
|
async refetch() {
|
||||||
|
await this.#observer.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
async prefetch(params: FontStoreParams) {
|
||||||
|
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
|
||||||
|
}
|
||||||
|
|
||||||
|
getCachedData(): UnifiedFont[] | undefined {
|
||||||
|
const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
||||||
|
this.buildQueryKey(this.#params),
|
||||||
|
);
|
||||||
|
if (!data) return undefined;
|
||||||
|
return data.pages.flatMap(p => p.fonts);
|
||||||
|
}
|
||||||
|
|
||||||
|
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
||||||
|
const key = this.buildQueryKey(this.#params);
|
||||||
|
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
||||||
|
key,
|
||||||
|
old => {
|
||||||
|
const flatFonts = old?.pages.flatMap(p => p.fonts);
|
||||||
|
const newFonts = updater(flatFonts);
|
||||||
|
// Re-distribute the updated fonts back into the existing page structure
|
||||||
|
// Define the first page. If old data exists, we merge into the first page template.
|
||||||
|
const limit = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
|
||||||
|
const template = old?.pages[0] ?? {
|
||||||
|
total: newFonts.length,
|
||||||
|
limit,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedPage: ProxyFontsResponse = {
|
||||||
|
...template,
|
||||||
|
fonts: newFonts,
|
||||||
|
total: newFonts.length, // Synchronize total with the new font count
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pages: [updatedPage],
|
||||||
|
pageParams: [{ offset: 0 }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Filter shortcuts --
|
||||||
|
|
||||||
|
setProviders(v: ProxyFontsParams['providers']) {
|
||||||
|
this.setParams({ providers: v });
|
||||||
|
}
|
||||||
|
setCategories(v: ProxyFontsParams['categories']) {
|
||||||
|
this.setParams({ categories: v });
|
||||||
|
}
|
||||||
|
setSubsets(v: ProxyFontsParams['subsets']) {
|
||||||
|
this.setParams({ subsets: v });
|
||||||
|
}
|
||||||
|
setSearch(v: string) {
|
||||||
|
this.setParams({ q: v || undefined });
|
||||||
|
}
|
||||||
|
setSort(v: ProxyFontsParams['sort']) {
|
||||||
|
this.setParams({ sort: v });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Pagination navigation --
|
||||||
|
|
||||||
|
async nextPage(): Promise<void> {
|
||||||
|
await this.#observer.fetchNextPage();
|
||||||
|
}
|
||||||
|
prevPage(): void {} // no-op: infinite scroll accumulates forward only; method kept for API compatibility
|
||||||
|
goToPage(_page: number): void {} // no-op
|
||||||
|
|
||||||
|
setLimit(limit: number) {
|
||||||
|
this.setParams({ limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Category views --
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Private helpers (TypeScript-private so tests can spy via `as any`) --
|
||||||
|
|
||||||
|
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
|
||||||
|
const filtered: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
// Ensure we DO NOT 'continue' or skip the limit key here.
|
||||||
|
// The limit is a fundamental part of the data identity.
|
||||||
|
if (
|
||||||
|
value !== undefined
|
||||||
|
&& value !== null
|
||||||
|
&& value !== ''
|
||||||
|
&& !(Array.isArray(value) && value.length === 0)
|
||||||
|
) {
|
||||||
|
filtered[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['fonts', filtered];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildOptions(params = this.#params) {
|
||||||
|
const activeParams = { ...params };
|
||||||
|
const hasFilters = !!(
|
||||||
|
activeParams.q
|
||||||
|
|| (Array.isArray(activeParams.providers) && activeParams.providers.length > 0)
|
||||||
|
|| (Array.isArray(activeParams.categories) && activeParams.categories.length > 0)
|
||||||
|
|| (Array.isArray(activeParams.subsets) && activeParams.subsets.length > 0)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
queryKey: this.buildQueryKey(activeParams),
|
||||||
|
queryFn: ({ pageParam }: QueryFunctionContext<readonly unknown[], PageParam>) =>
|
||||||
|
this.fetchPage({ ...activeParams, ...pageParam }),
|
||||||
|
initialPageParam: { offset: 0 } as PageParam,
|
||||||
|
getNextPageParam: (lastPage: ProxyFontsResponse): PageParam | undefined => {
|
||||||
|
const next = lastPage.offset + lastPage.limit;
|
||||||
|
return next < lastPage.total ? { offset: next } : undefined;
|
||||||
|
},
|
||||||
|
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchPage(params: ProxyFontsParams): Promise<ProxyFontsResponse> {
|
||||||
|
let response: ProxyFontsResponse;
|
||||||
|
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);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fonts: response.fonts,
|
||||||
|
total: response.total ?? 0,
|
||||||
|
limit: response.limit ?? params.limit ?? 50,
|
||||||
|
offset: response.offset ?? params.offset ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFontStore(params: FontStoreParams = {}): FontStore {
|
||||||
|
return new FontStore(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fontStore = new FontStore({ limit: 50 });
|
||||||
@@ -6,12 +6,12 @@
|
|||||||
* 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';
|
||||||
|
|
||||||
|
// Single FontStore (new implementation)
|
||||||
|
export {
|
||||||
|
createFontStore,
|
||||||
|
FontStore,
|
||||||
|
fontStore,
|
||||||
|
} from './fontStore/fontStore.svelte';
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
unifiedFontStore,
|
fontStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props extends
|
interface Props extends
|
||||||
@@ -50,7 +50,7 @@ let {
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const isLoading = $derived(
|
const isLoading = $derived(
|
||||||
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
|
fontStore.isFetching || fontStore.isLoading,
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||||
@@ -82,12 +82,12 @@ function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
|||||||
*/
|
*/
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
if (
|
if (
|
||||||
!unifiedFontStore.pagination.hasMore
|
!fontStore.pagination.hasMore
|
||||||
|| unifiedFontStore.isFetching
|
|| fontStore.isFetching
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unifiedFontStore.nextPage();
|
fontStore.nextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,17 +97,17 @@ function loadMore() {
|
|||||||
* of the loaded items. Only fetches if there are more pages available.
|
* of the loaded items. Only fetches if there are more pages available.
|
||||||
*/
|
*/
|
||||||
function handleNearBottom(_lastVisibleIndex: number) {
|
function handleNearBottom(_lastVisibleIndex: number) {
|
||||||
const { hasMore } = unifiedFontStore.pagination;
|
const { hasMore } = fontStore.pagination;
|
||||||
|
|
||||||
// VirtualList already checks if we're near the bottom of loaded items
|
// VirtualList already checks if we're near the bottom of loaded items
|
||||||
if (hasMore && !unifiedFontStore.isFetching) {
|
if (hasMore && !fontStore.isFetching) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative w-full h-full">
|
<div class="relative w-full h-full">
|
||||||
{#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
|
{#if skeleton && isLoading && fontStore.fonts.length === 0}
|
||||||
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
||||||
<div transition:fade={{ duration: 300 }}>
|
<div transition:fade={{ duration: 300 }}>
|
||||||
{@render skeleton()}
|
{@render skeleton()}
|
||||||
@@ -115,8 +115,8 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||||
<VirtualList
|
<VirtualList
|
||||||
items={unifiedFontStore.fonts}
|
items={fontStore.fonts}
|
||||||
total={unifiedFontStore.pagination.total}
|
total={fontStore.pagination.total}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
onNearBottom={handleNearBottom}
|
onNearBottom={handleNearBottom}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
Sits below the filter list, separated by a top border.
|
Sits below the filter list, separated by a top border.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { unifiedFontStore } from '$entities/Font';
|
import { fontStore } from '$entities/Font';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import { Button } from '$shared/ui';
|
import { Button } from '$shared/ui';
|
||||||
@@ -33,7 +33,7 @@ const {
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const apiSort = sortStore.apiValue;
|
const apiSort = sortStore.apiValue;
|
||||||
untrack(() => unifiedFontStore.setSort(apiSort));
|
untrack(() => fontStore.setSort(apiSort));
|
||||||
});
|
});
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import {
|
import {
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
unifiedFontStore,
|
fontStore,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import {
|
import {
|
||||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
@@ -85,7 +85,7 @@ export class ComparisonStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if fonts are available to set as defaults
|
// Check if fonts are available to set as defaults
|
||||||
const fonts = unifiedFontStore.fonts;
|
const fonts = fontStore.fonts;
|
||||||
if (fonts.length >= 2) {
|
if (fonts.length >= 2) {
|
||||||
// Only set if we really have nothing (fallback)
|
// Only set if we really have nothing (fallback)
|
||||||
if (!this.#fontA) this.#fontA = fonts[0];
|
if (!this.#fontA) this.#fontA = fonts[0];
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Tests the font comparison store functionality including:
|
* Tests the font comparison store functionality including:
|
||||||
* - Font loading via CSS Font Loading API
|
* - Font loading via CSS Font Loading API
|
||||||
* - Storage synchronization when fonts change
|
* - Storage synchronization when fonts change
|
||||||
* - Default values from unifiedFontStore
|
* - Default values from fontStore
|
||||||
* - Reset functionality
|
* - Reset functionality
|
||||||
* - isReady computed state
|
* - isReady computed state
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
// Mock all dependencies
|
// Mock all dependencies
|
||||||
vi.mock('$entities/Font', () => ({
|
vi.mock('$entities/Font', () => ({
|
||||||
fetchFontsByIds: vi.fn(),
|
fetchFontsByIds: vi.fn(),
|
||||||
unifiedFontStore: { fonts: [] },
|
fontStore: { fonts: [] },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$features/SetupFont', () => ({
|
vi.mock('$features/SetupFont', () => ({
|
||||||
@@ -119,7 +119,7 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte'
|
|||||||
// Import after mocks
|
// Import after mocks
|
||||||
import {
|
import {
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
unifiedFontStore,
|
fontStore,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { createTypographyControlManager } from '$features/SetupFont';
|
import { createTypographyControlManager } from '$features/SetupFont';
|
||||||
import { ComparisonStore } from './comparisonStore.svelte';
|
import { ComparisonStore } from './comparisonStore.svelte';
|
||||||
@@ -150,8 +150,8 @@ describe('ComparisonStore', () => {
|
|||||||
};
|
};
|
||||||
mockStorage._clear.mockClear();
|
mockStorage._clear.mockClear();
|
||||||
|
|
||||||
// Setup mock unifiedFontStore
|
// Setup mock fontStore
|
||||||
(unifiedFontStore as any).fonts = [];
|
(fontStore as any).fonts = [];
|
||||||
|
|
||||||
// Setup mock fetchFontsByIds
|
// Setup mock fetchFontsByIds
|
||||||
vi.mocked(fetchFontsByIds).mockResolvedValue([]);
|
vi.mocked(fetchFontsByIds).mockResolvedValue([]);
|
||||||
@@ -301,8 +301,8 @@ describe('ComparisonStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle partial restoration when only one font is found', async () => {
|
it('should handle partial restoration when only one font is found', async () => {
|
||||||
// Ensure unifiedFontStore is empty so $effect doesn't interfere
|
// Ensure fontStore is empty so $effect doesn't interfere
|
||||||
(unifiedFontStore as any).fonts = [];
|
(fontStore as any).fonts = [];
|
||||||
|
|
||||||
mockStorage._value.fontAId = mockFontA.id;
|
mockStorage._value.fontAId = mockFontA.id;
|
||||||
mockStorage._value.fontBId = mockFontB.id;
|
mockStorage._value.fontBId = mockFontB.id;
|
||||||
@@ -330,7 +330,7 @@ describe('ComparisonStore', () => {
|
|||||||
describe('Font Loading with CSS Font Loading API', () => {
|
describe('Font Loading with CSS Font Loading API', () => {
|
||||||
it('should construct correct font strings for checking', async () => {
|
it('should construct correct font strings for checking', async () => {
|
||||||
mockFontFaceSet.check.mockReturnValue(false);
|
mockFontFaceSet.check.mockReturnValue(false);
|
||||||
(unifiedFontStore as any).fonts = [mockFontA, mockFontB];
|
(fontStore as any).fonts = [mockFontA, mockFontB];
|
||||||
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
|
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
|
||||||
|
|
||||||
const store = new ComparisonStore();
|
const store = new ComparisonStore();
|
||||||
@@ -367,7 +367,7 @@ describe('ComparisonStore', () => {
|
|||||||
// Mock load to fail
|
// Mock load to fail
|
||||||
mockFontFaceSet.load.mockRejectedValue(new Error('Font load failed'));
|
mockFontFaceSet.load.mockRejectedValue(new Error('Font load failed'));
|
||||||
|
|
||||||
(unifiedFontStore as any).fonts = [mockFontA, mockFontB];
|
(fontStore as any).fonts = [mockFontA, mockFontB];
|
||||||
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
|
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
@@ -384,11 +384,11 @@ describe('ComparisonStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Default Values from unifiedFontStore', () => {
|
describe('Default Values from fontStore', () => {
|
||||||
it('should set default fonts from unifiedFontStore when available', () => {
|
it('should set default fonts from fontStore when available', () => {
|
||||||
// Note: This test relies on Svelte 5's $effect which may not work
|
// Note: This test relies on Svelte 5's $effect which may not work
|
||||||
// reliably in the test environment. We test the logic path instead.
|
// reliably in the test environment. We test the logic path instead.
|
||||||
(unifiedFontStore as any).fonts = [mockFontA, mockFontB];
|
(fontStore as any).fonts = [mockFontA, mockFontB];
|
||||||
|
|
||||||
const store = new ComparisonStore();
|
const store = new ComparisonStore();
|
||||||
|
|
||||||
@@ -402,9 +402,9 @@ describe('ComparisonStore', () => {
|
|||||||
expect(store.fontB).toBeDefined();
|
expect(store.fontB).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use first and last font from unifiedFontStore as defaults', () => {
|
it('should use first and last font from fontStore as defaults', () => {
|
||||||
const mockFontC = UNIFIED_FONTS.lato;
|
const mockFontC = UNIFIED_FONTS.lato;
|
||||||
(unifiedFontStore as any).fonts = [mockFontA, mockFontC, mockFontB];
|
(fontStore as any).fonts = [mockFontA, mockFontC, mockFontB];
|
||||||
|
|
||||||
const store = new ComparisonStore();
|
const store = new ComparisonStore();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Provides a search input and filtration for fonts
|
Provides a search input and filtration for fonts
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { unifiedFontStore } from '$entities/Font';
|
import { fontStore } from '$entities/Font';
|
||||||
import {
|
import {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
Filters,
|
||||||
@@ -36,7 +36,7 @@ let { showFilters = $bindable(true) }: Props = $props();
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const params = mapManagerToParams(filterManager);
|
const params = mapManagerToParams(filterManager);
|
||||||
untrack(() => unifiedFontStore.setParams(params));
|
untrack(() => fontStore.setParams(params));
|
||||||
});
|
});
|
||||||
|
|
||||||
const transform = new Tween(
|
const transform = new Tween(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { NavigationWrapper } from '$entities/Breadcrumb';
|
import { NavigationWrapper } from '$entities/Breadcrumb';
|
||||||
import { unifiedFontStore } from '$entities/Font';
|
import { fontStore } from '$entities/Font';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +36,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
|
|||||||
id="sample_set"
|
id="sample_set"
|
||||||
title="Sample Set"
|
title="Sample Set"
|
||||||
headerTitle="visual_output"
|
headerTitle="visual_output"
|
||||||
headerSubtitle="items_total: {unifiedFontStore.pagination.total ?? 0}"
|
headerSubtitle="items_total: {fontStore.pagination.total ?? 0}"
|
||||||
headerAction={registerAction}
|
headerAction={registerAction}
|
||||||
>
|
>
|
||||||
{#snippet headerContent()}
|
{#snippet headerContent()}
|
||||||
|
|||||||
Reference in New Issue
Block a user