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 {
appliedFontsManager,
createFontStore,
fontStore,
} from './model';
export * from './api';
export * from './model';
export * from './ui';

View File

@@ -20,7 +20,7 @@
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
*
* // Use preset stores
* const mockFontStore = MOCK_STORES.unifiedFontStore();
* const mockFontStore = createMockFontStore();
* ```
*/
@@ -459,6 +459,117 @@ export const MOCK_STORES = {
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

View File

@@ -4,3 +4,4 @@ export {
FontStore,
fontStore,
} 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] = {}) {
const store = makeStore(params);
fetch.mockResolvedValue(makeResponse(fonts, meta));
const store = makeStore(params);
await store.refetch();
flushSync();
return store;
@@ -138,8 +138,8 @@ describe('FontStore', () => {
});
it('wraps network failures in FontNetworkError', async () => {
const store = makeStore();
fetch.mockRejectedValue(new Error('network down'));
const store = makeStore();
await store.refetch().catch(() => {});
flushSync();
expect(store.error).toBeInstanceOf(FontNetworkError);
@@ -158,8 +158,8 @@ describe('FontStore', () => {
});
it('exposes FontResponseError for missing fonts field', async () => {
const store = makeStore();
fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 });
const store = makeStore();
await store.refetch().catch(() => {});
flushSync();
expect(store.error).toBeInstanceOf(FontResponseError);
@@ -168,8 +168,8 @@ describe('FontStore', () => {
});
it('exposes FontResponseError for non-array fonts', async () => {
const store = makeStore();
fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 });
const store = makeStore();
await store.refetch().catch(() => {});
flushSync();
expect(store.error).toBeInstanceOf(FontResponseError);
@@ -198,12 +198,8 @@ describe('FontStore', () => {
});
it('appends fonts after nextPage', async () => {
const store = makeStore();
const page1 = generateMockFonts(3);
fetch.mockResolvedValue(makeResponse(page1, { total: 6, limit: 3, offset: 0 }));
await store.refetch();
flushSync();
const 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();
@@ -240,10 +236,7 @@ describe('FontStore', () => {
});
it('page count increments after nextPage', async () => {
const store = makeStore();
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
await store.refetch();
flushSync();
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 }));
@@ -571,10 +564,11 @@ describe('FontStore', () => {
// -----------------------------------------------------------------------
describe('category getters', () => {
it('each getter returns only fonts of that category', async () => {
const store = makeStore({ limit: 50 });
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
fetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
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);

View File

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

View File

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