test: mock fontStore and update FontStore type signatures
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
export {
|
export * from './api';
|
||||||
appliedFontsManager,
|
export * from './model';
|
||||||
createFontStore,
|
export * from './ui';
|
||||||
fontStore,
|
|
||||||
} from './model';
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export {
|
|||||||
FontStore,
|
FontStore,
|
||||||
fontStore,
|
fontStore,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
export * from './types';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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', () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user