test: mock fontStore and update FontStore type signatures
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user