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

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