test: mock fontStore and update FontStore type signatures

This commit is contained in:
Ilia Mashkov
2026-04-09 19:40:31 +03:00
parent 45eac0c396
commit dc6e15492a
6 changed files with 180 additions and 54 deletions

View File

@@ -1,5 +1,3 @@
export { export * from './api';
appliedFontsManager, export * from './model';
createFontStore, export * from './ui';
fontStore,
} from './model';

View File

@@ -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

View File

@@ -4,3 +4,4 @@ export {
FontStore, FontStore,
fontStore, fontStore,
} from './store'; } from './store';
export * from './types';

View File

@@ -48,8 +48,8 @@ function makeStore(params = {}) {
} }
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) { async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
const store = makeStore(params);
fetch.mockResolvedValue(makeResponse(fonts, meta)); fetch.mockResolvedValue(makeResponse(fonts, meta));
const store = makeStore(params);
await store.refetch(); await store.refetch();
flushSync(); flushSync();
return store; return store;
@@ -138,8 +138,8 @@ describe('FontStore', () => {
}); });
it('wraps network failures in FontNetworkError', async () => { it('wraps network failures in FontNetworkError', async () => {
const store = makeStore();
fetch.mockRejectedValue(new Error('network down')); fetch.mockRejectedValue(new Error('network down'));
const store = makeStore();
await store.refetch().catch(() => {}); await store.refetch().catch(() => {});
flushSync(); flushSync();
expect(store.error).toBeInstanceOf(FontNetworkError); expect(store.error).toBeInstanceOf(FontNetworkError);
@@ -158,8 +158,8 @@ describe('FontStore', () => {
}); });
it('exposes FontResponseError for missing fonts field', async () => { it('exposes FontResponseError for missing fonts field', async () => {
const store = makeStore();
fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 }); fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 });
const store = makeStore();
await store.refetch().catch(() => {}); await store.refetch().catch(() => {});
flushSync(); flushSync();
expect(store.error).toBeInstanceOf(FontResponseError); expect(store.error).toBeInstanceOf(FontResponseError);
@@ -168,8 +168,8 @@ describe('FontStore', () => {
}); });
it('exposes FontResponseError for non-array fonts', async () => { it('exposes FontResponseError for non-array fonts', async () => {
const store = makeStore();
fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 }); fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 });
const store = makeStore();
await store.refetch().catch(() => {}); await store.refetch().catch(() => {});
flushSync(); flushSync();
expect(store.error).toBeInstanceOf(FontResponseError); expect(store.error).toBeInstanceOf(FontResponseError);
@@ -198,12 +198,8 @@ describe('FontStore', () => {
}); });
it('appends fonts after nextPage', async () => { it('appends fonts after nextPage', async () => {
const store = makeStore();
const page1 = generateMockFonts(3); const page1 = generateMockFonts(3);
fetch.mockResolvedValue(makeResponse(page1, { total: 6, limit: 3, offset: 0 })); const store = await fetchedStore({ limit: 3 }, page1, { total: 6, limit: 3, offset: 0 });
await store.refetch();
flushSync();
const page2 = generateMockFonts(3).map((f, i) => ({ ...f, id: `p2-${i}` })); const page2 = generateMockFonts(3).map((f, i) => ({ ...f, id: `p2-${i}` }));
fetch.mockResolvedValue(makeResponse(page2, { total: 6, limit: 3, offset: 3 })); fetch.mockResolvedValue(makeResponse(page2, { total: 6, limit: 3, offset: 3 }));
await store.nextPage(); await store.nextPage();
@@ -240,10 +236,7 @@ describe('FontStore', () => {
}); });
it('page count increments after nextPage', async () => { it('page count increments after nextPage', async () => {
const store = makeStore(); const store = await fetchedStore({ limit: 10 }, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
await store.refetch();
flushSync();
expect(store.pagination.page).toBe(1); expect(store.pagination.page).toBe(1);
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 })); fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
@@ -571,10 +564,11 @@ describe('FontStore', () => {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
describe('category getters', () => { describe('category getters', () => {
it('each getter returns only fonts of that category', async () => { it('each getter returns only fonts of that category', async () => {
const store = makeStore({ limit: 50 });
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
fetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length })); fetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
const store = makeStore({ limit: 50 });
await store.refetch(); await store.refetch();
flushSync();
expect(store.sansSerifFonts.every(f => f.category === 'sans-serif')).toBe(true); expect(store.sansSerifFonts.every(f => f.category === 'sans-serif')).toBe(true);
expect(store.serifFonts.every(f => f.category === 'serif')).toBe(true); expect(store.serifFonts.every(f => f.category === 'serif')).toBe(true);

View File

@@ -16,28 +16,23 @@ import {
} from '../../../lib/errors/errors'; } from '../../../lib/errors/errors';
import type { UnifiedFont } from '../../types'; import type { UnifiedFont } from '../../types';
/**
* Shape of a single page as returned by the proxy API and stored by TQ.
* Each entry in `result.data.pages` is a `FontPage`.
*/
type FontPage = {
fonts: UnifiedFont[];
total: number;
limit: number;
offset: number;
};
type PageParam = { offset: number }; type PageParam = { offset: number };
/** Filter params + limit — offset is managed by TQ as a page param, not a user param. */ /** Filter params + limit — offset is managed by TQ as a page param, not a user param. */
type FontStoreParams = Omit<ProxyFontsParams, 'offset'>; type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<FontPage, PageParam>, Error>; type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
export class FontStore { export class FontStore {
#params = $state<FontStoreParams>({ limit: 50 }); #params = $state<FontStoreParams>({ limit: 50 });
#result = $state<FontStoreResult>({} as FontStoreResult); #result = $state<FontStoreResult>({} as FontStoreResult);
#observer: InfiniteQueryObserver<FontPage, Error, InfiniteData<FontPage, PageParam>, readonly unknown[], PageParam>; #observer: InfiniteQueryObserver<
ProxyFontsResponse,
Error,
InfiniteData<ProxyFontsResponse, PageParam>,
readonly unknown[],
PageParam
>;
#qc = queryClient; #qc = queryClient;
#unsubscribe: () => void; #unsubscribe: () => void;
@@ -55,7 +50,7 @@ export class FontStore {
return this.#params; return this.#params;
} }
get fonts(): UnifiedFont[] { get fonts(): UnifiedFont[] {
return this.#result.data?.pages.flatMap((p: FontPage) => p.fonts) ?? []; return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
} }
get isLoading(): boolean { get isLoading(): boolean {
return this.#result.isLoading; return this.#result.isLoading;
@@ -120,7 +115,6 @@ export class FontStore {
// -- Async operations -- // -- Async operations --
async refetch() { async refetch() {
this.#observer.setOptions(this.buildOptions());
await this.#observer.refetch(); await this.#observer.refetch();
} }
@@ -133,7 +127,7 @@ export class FontStore {
} }
getCachedData(): UnifiedFont[] | undefined { getCachedData(): UnifiedFont[] | undefined {
const data = this.#qc.getQueryData<InfiniteData<FontPage, PageParam>>( const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
this.buildQueryKey(this.#params), this.buildQueryKey(this.#params),
); );
if (!data) return undefined; if (!data) return undefined;
@@ -141,15 +135,30 @@ export class FontStore {
} }
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) { setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
this.#qc.setQueryData<InfiniteData<FontPage, PageParam>>( const key = this.buildQueryKey(this.#params);
this.buildQueryKey(this.#params), this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
key,
old => { old => {
const flatFonts = old?.pages.flatMap(p => p.fonts); const flatFonts = old?.pages.flatMap(p => p.fonts);
const newFonts = updater(flatFonts); const newFonts = updater(flatFonts);
const template = old?.pages[0] ?? { total: newFonts.length, limit: newFonts.length, offset: 0 }; // 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 { return {
pages: [{ ...template, fonts: newFonts }], pages: [updatedPage],
pageParams: [{ offset: 0 } as PageParam], pageParams: [{ offset: 0 }],
}; };
}, },
); );
@@ -206,26 +215,38 @@ export class FontStore {
// -- Private helpers (TypeScript-private so tests can spy via `as any`) -- // -- Private helpers (TypeScript-private so tests can spy via `as any`) --
private buildQueryKey(params: FontStoreParams): readonly unknown[] { private buildQueryKey(params: FontStoreParams): readonly unknown[] {
const normalized = Object.entries(params).reduce<Record<string, unknown>>((acc, [k, v]) => { const filtered: Record<string, any> = {};
if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) return acc;
return { ...acc, [k]: v }; for (const [key, value] of Object.entries(params)) {
}, {}); // Ensure we DO NOT 'continue' or skip the limit key here.
return ['fonts', normalized] as const; // 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) { private buildOptions(params = this.#params) {
const activeParams = { ...params };
const hasFilters = !!( const hasFilters = !!(
params.q activeParams.q
|| (Array.isArray(params.providers) && params.providers.length > 0) || (Array.isArray(activeParams.providers) && activeParams.providers.length > 0)
|| (Array.isArray(params.categories) && params.categories.length > 0) || (Array.isArray(activeParams.categories) && activeParams.categories.length > 0)
|| (Array.isArray(params.subsets) && params.subsets.length > 0) || (Array.isArray(activeParams.subsets) && activeParams.subsets.length > 0)
); );
return { return {
queryKey: this.buildQueryKey(params), queryKey: this.buildQueryKey(activeParams),
queryFn: ({ pageParam }: QueryFunctionContext<readonly unknown[], PageParam>) => queryFn: ({ pageParam }: QueryFunctionContext<readonly unknown[], PageParam>) =>
this.fetchPage({ ...this.#params, ...pageParam }), this.fetchPage({ ...activeParams, ...pageParam }),
initialPageParam: { offset: 0 } as PageParam, initialPageParam: { offset: 0 } as PageParam,
getNextPageParam: (lastPage: FontPage): PageParam | undefined => { getNextPageParam: (lastPage: ProxyFontsResponse): PageParam | undefined => {
const next = lastPage.offset + lastPage.limit; const next = lastPage.offset + lastPage.limit;
return next < lastPage.total ? { offset: next } : undefined; return next < lastPage.total ? { offset: next } : undefined;
}, },
@@ -234,7 +255,7 @@ export class FontStore {
}; };
} }
private async fetchPage(params: ProxyFontsParams): Promise<FontPage> { private async fetchPage(params: ProxyFontsParams): Promise<ProxyFontsResponse> {
let response: ProxyFontsResponse; let response: ProxyFontsResponse;
try { try {
response = await fetchProxyFonts(params); response = await fetchProxyFonts(params);

View File

@@ -26,6 +26,7 @@ import {
vi.mock('$entities/Font', () => ({ vi.mock('$entities/Font', () => ({
fetchFontsByIds: vi.fn(), fetchFontsByIds: vi.fn(),
unifiedFontStore: { fonts: [] }, unifiedFontStore: { fonts: [] },
fontStore: { fonts: [] },
})); }));
vi.mock('$features/SetupFont', () => ({ vi.mock('$features/SetupFont', () => ({