feature/fetch-fonts #14
@@ -1,297 +0,0 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type { UnifiedFont } from '../model/types/normalize';
|
||||
import {
|
||||
filterFonts,
|
||||
sortFonts,
|
||||
} from './filterUtils';
|
||||
|
||||
const createMockFont = (overrides: Partial<UnifiedFont> = {}): UnifiedFont => ({
|
||||
id: 'test-1',
|
||||
name: 'Test Font',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin'],
|
||||
variants: ['400'],
|
||||
styles: {
|
||||
regular: 'https://example.com/font.woff2',
|
||||
},
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
popularity: 100,
|
||||
},
|
||||
features: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('filterUtils', () => {
|
||||
describe('filterFonts', () => {
|
||||
it('should return all fonts when no filters are applied', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', name: 'Arial' }),
|
||||
createMockFont({ id: '2', name: 'Times New Roman' }),
|
||||
];
|
||||
|
||||
const result = filterFonts(fonts, {
|
||||
providers: [],
|
||||
categories: [],
|
||||
subsets: [],
|
||||
searchQuery: '',
|
||||
});
|
||||
|
||||
expect(result).toEqual(fonts);
|
||||
});
|
||||
|
||||
it('should filter by provider', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', provider: 'google' }),
|
||||
createMockFont({ id: '2', provider: 'fontshare' }),
|
||||
];
|
||||
|
||||
const result = filterFonts(fonts, {
|
||||
providers: ['google'],
|
||||
categories: [],
|
||||
subsets: [],
|
||||
searchQuery: '',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].provider).toBe('google');
|
||||
});
|
||||
|
||||
it('should filter by category', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', category: 'sans-serif' }),
|
||||
createMockFont({ id: '2', category: 'serif' }),
|
||||
];
|
||||
|
||||
const result = filterFonts(fonts, {
|
||||
providers: [],
|
||||
categories: ['sans-serif'],
|
||||
subsets: [],
|
||||
searchQuery: '',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('should filter by subsets', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', subsets: ['latin'] }),
|
||||
createMockFont({ id: '2', subsets: ['cyrillic'] }),
|
||||
];
|
||||
|
||||
const result = filterFonts(fonts, {
|
||||
providers: [],
|
||||
categories: [],
|
||||
subsets: ['latin'],
|
||||
searchQuery: '',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].subsets).toContain('latin');
|
||||
});
|
||||
|
||||
it('should filter by search query (case-insensitive)', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', name: 'Roboto' }),
|
||||
createMockFont({ id: '2', name: 'Open Sans' }),
|
||||
];
|
||||
|
||||
const result = filterFonts(fonts, {
|
||||
providers: [],
|
||||
categories: [],
|
||||
subsets: [],
|
||||
searchQuery: 'ROBO',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Roboto');
|
||||
});
|
||||
|
||||
it('should apply multiple filters simultaneously', () => {
|
||||
const fonts = [
|
||||
createMockFont({
|
||||
id: '1',
|
||||
name: 'Roboto',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin'],
|
||||
}),
|
||||
createMockFont({
|
||||
id: '2',
|
||||
name: 'Playfair',
|
||||
provider: 'google',
|
||||
category: 'serif',
|
||||
subsets: ['latin'],
|
||||
}),
|
||||
createMockFont({
|
||||
id: '3',
|
||||
name: 'Lato',
|
||||
provider: 'fontshare',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin'],
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterFonts(fonts, {
|
||||
providers: ['google'],
|
||||
categories: ['sans-serif'],
|
||||
subsets: [],
|
||||
searchQuery: '',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Roboto');
|
||||
});
|
||||
|
||||
it('should handle empty result set', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', name: 'Roboto' }),
|
||||
];
|
||||
|
||||
const result = filterFonts(fonts, {
|
||||
providers: ['fontshare'],
|
||||
categories: [],
|
||||
subsets: [],
|
||||
searchQuery: '',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should be case-insensitive for search', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', name: 'Roboto' }),
|
||||
createMockFont({ id: '2', name: 'roboto-condensed' }),
|
||||
];
|
||||
|
||||
const result = filterFonts(fonts, {
|
||||
providers: [],
|
||||
categories: [],
|
||||
subsets: [],
|
||||
searchQuery: 'ROBO',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortFonts', () => {
|
||||
it('should sort by name ascending', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', name: 'Zebra' }),
|
||||
createMockFont({ id: '2', name: 'Apple' }),
|
||||
createMockFont({ id: '3', name: 'Middle' }),
|
||||
];
|
||||
|
||||
const result = sortFonts(fonts, { field: 'name', direction: 'asc' });
|
||||
|
||||
expect(result[0].name).toBe('Apple');
|
||||
expect(result[1].name).toBe('Middle');
|
||||
expect(result[2].name).toBe('Zebra');
|
||||
});
|
||||
|
||||
it('should sort by name descending', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', name: 'Zebra' }),
|
||||
createMockFont({ id: '2', name: 'Apple' }),
|
||||
];
|
||||
|
||||
const result = sortFonts(fonts, { field: 'name', direction: 'desc' });
|
||||
|
||||
expect(result[0].name).toBe('Zebra');
|
||||
expect(result[1].name).toBe('Apple');
|
||||
});
|
||||
|
||||
it('should sort by category', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', category: 'serif' }),
|
||||
createMockFont({ id: '2', category: 'sans-serif' }),
|
||||
createMockFont({ id: '3', category: 'display' }),
|
||||
];
|
||||
|
||||
const result = sortFonts(fonts, { field: 'category', direction: 'asc' });
|
||||
|
||||
expect(result[0].category).toBe('display');
|
||||
expect(result[1].category).toBe('sans-serif');
|
||||
expect(result[2].category).toBe('serif');
|
||||
});
|
||||
|
||||
it('should sort by popularity (most popular first for asc)', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', metadata: { cachedAt: Date.now(), popularity: 10 } }),
|
||||
createMockFont({ id: '2', metadata: { cachedAt: Date.now(), popularity: 100 } }),
|
||||
createMockFont({ id: '3', metadata: { cachedAt: Date.now(), popularity: 50 } }),
|
||||
];
|
||||
|
||||
const result = sortFonts(fonts, { field: 'popularity', direction: 'asc' });
|
||||
|
||||
// Higher popularity comes first
|
||||
expect(result[0].metadata.popularity).toBe(100);
|
||||
expect(result[1].metadata.popularity).toBe(50);
|
||||
expect(result[2].metadata.popularity).toBe(10);
|
||||
});
|
||||
|
||||
it('should sort by date (most recent first for asc)', () => {
|
||||
const now = Date.now();
|
||||
const fonts = [
|
||||
createMockFont({
|
||||
id: '1',
|
||||
metadata: { cachedAt: now - 10000, popularity: 0 },
|
||||
}),
|
||||
createMockFont({
|
||||
id: '2',
|
||||
metadata: { cachedAt: now, popularity: 0 },
|
||||
}),
|
||||
createMockFont({
|
||||
id: '3',
|
||||
metadata: { cachedAt: now - 5000, popularity: 0 },
|
||||
}),
|
||||
];
|
||||
|
||||
const result = sortFonts(fonts, { field: 'date', direction: 'asc' });
|
||||
|
||||
// Most recent comes first
|
||||
expect(result[0].metadata.cachedAt).toBe(now);
|
||||
expect(result[1].metadata.cachedAt).toBe(now - 5000);
|
||||
expect(result[2].metadata.cachedAt).toBe(now - 10000);
|
||||
});
|
||||
|
||||
it('should handle undefined popularity values', () => {
|
||||
const fonts = [
|
||||
createMockFont({
|
||||
id: '1',
|
||||
metadata: { cachedAt: Date.now(), popularity: 50 },
|
||||
}),
|
||||
createMockFont({
|
||||
id: '2',
|
||||
metadata: { cachedAt: Date.now() },
|
||||
}),
|
||||
];
|
||||
|
||||
const result = sortFonts(fonts, { field: 'popularity', direction: 'asc' });
|
||||
|
||||
// Undefined should be treated as 0
|
||||
expect(result[0].metadata.popularity).toBe(50);
|
||||
expect(result[1].metadata.popularity).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should return a new array without modifying original', () => {
|
||||
const fonts = [
|
||||
createMockFont({ id: '1', name: 'Zebra' }),
|
||||
createMockFont({ id: '2', name: 'Apple' }),
|
||||
];
|
||||
|
||||
const result = sortFonts(fonts, { field: 'name', direction: 'asc' });
|
||||
|
||||
expect(result).not.toBe(fonts);
|
||||
expect(result[0].name).toBe('Apple');
|
||||
expect(fonts[0].name).toBe('Zebra'); // Original unchanged
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* FONT FILTER UTILITIES
|
||||
* ============================================================================
|
||||
*
|
||||
* Optimized utilities for filtering and sorting fonts in a single pass.
|
||||
* These utilities are entity-specific and located in the Font entity layer
|
||||
* following FSD (Feature-Sliced Design) principles.
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontFilters,
|
||||
FontSort,
|
||||
} from '../model/store/types';
|
||||
import type { UnifiedFont } from '../model/types/normalize';
|
||||
|
||||
/**
|
||||
* Single-pass filter function for fonts
|
||||
*
|
||||
* Applies all filters in a single iteration for O(n) complexity.
|
||||
* This is more efficient than chaining multiple filter() calls which
|
||||
* would create intermediate arrays.
|
||||
*
|
||||
* @param fonts - The fonts to filter
|
||||
* @param filters - The filter criteria
|
||||
* @returns Filtered fonts
|
||||
*/
|
||||
export function filterFonts(
|
||||
fonts: UnifiedFont[],
|
||||
filters: FontFilters,
|
||||
): UnifiedFont[] {
|
||||
const hasSearch = !!filters.searchQuery;
|
||||
const hasProviders = filters.providers.length > 0;
|
||||
const hasCategories = filters.categories.length > 0;
|
||||
const hasSubsets = filters.subsets.length > 0;
|
||||
|
||||
// Fast path: no filters
|
||||
if (!hasSearch && !hasProviders && !hasCategories && !hasSubsets) {
|
||||
return fonts;
|
||||
}
|
||||
|
||||
const searchQuery = hasSearch ? filters.searchQuery.toLowerCase() : '';
|
||||
|
||||
// Single-pass filter
|
||||
return fonts.filter(font => {
|
||||
// Provider filter
|
||||
if (hasProviders && !filters.providers.includes(font.provider)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (hasCategories && !filters.categories.includes(font.category)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Subset filter
|
||||
if (hasSubsets && !filters.subsets.some(s => font.subsets.includes(s))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (hasSearch) {
|
||||
const nameMatch = font.name.toLowerCase().includes(searchQuery);
|
||||
const familyMatch = font.name.toLowerCase().includes(searchQuery);
|
||||
if (!nameMatch && !familyMatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort fonts by specified field and direction
|
||||
*
|
||||
* @param fonts - The fonts to sort
|
||||
* @param sort - The sort configuration
|
||||
* @returns Sorted fonts
|
||||
*/
|
||||
export function sortFonts(fonts: UnifiedFont[], sort: FontSort): UnifiedFont[] {
|
||||
const { field, direction } = sort;
|
||||
const multiplier = direction === 'asc' ? 1 : -1;
|
||||
|
||||
return [...fonts].sort((a, b) => {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name) * multiplier;
|
||||
case 'category':
|
||||
return a.category.localeCompare(b.category) * multiplier;
|
||||
case 'popularity':
|
||||
return ((b.metadata.popularity ?? 0) - (a.metadata.popularity ?? 0)) * multiplier;
|
||||
case 'date':
|
||||
// Sort by cachedAt timestamp as a proxy for date
|
||||
return ((b.metadata.cachedAt ?? 0) - (a.metadata.cachedAt ?? 0)) * multiplier;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* Font collection store
|
||||
*
|
||||
* Main font collection cache using Svelte stores.
|
||||
* Integrates with TanStack Query for advanced caching and deduplication.
|
||||
*
|
||||
* Provides derived stores for filtered/sorted fonts.
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
FontCollectionState,
|
||||
UnifiedFont,
|
||||
} from '$entities/Font/model/types';
|
||||
import { createCollectionCache } from '$shared/lib/fetch/collectionCache';
|
||||
|
||||
export const DEFAULT_SORT: FontCollectionSort = { field: 'name', direction: 'asc' };
|
||||
|
||||
/**
|
||||
* Create font collection store
|
||||
*
|
||||
* @param initialState - Initial state for collection
|
||||
* @returns Font collection store instance
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const fontCollection = createFontCollectionStore({
|
||||
* fonts: {},
|
||||
* filters: {},
|
||||
* sort: { field: 'name', direction: 'asc' }
|
||||
* });
|
||||
*
|
||||
* // Add fonts to collection
|
||||
* fontCollection.addFonts([font1, font2]);
|
||||
*
|
||||
* // Use in component
|
||||
* $fontCollection.filteredFonts
|
||||
* ```
|
||||
*/
|
||||
export function createFontCollection(
|
||||
initialState?: Partial<FontCollectionState>,
|
||||
) {
|
||||
const cache = createCollectionCache<UnifiedFont>({
|
||||
defaultTTL: 5 * 60 * 1000, // 5 minutes
|
||||
maxSize: 1000,
|
||||
});
|
||||
|
||||
let fonts = $state(initialState?.fonts ?? {});
|
||||
let filters = $state<FontCollectionFilters>(initialState?.filters ?? { searchQuery: '' });
|
||||
let sort = $state<FontCollectionSort>(initialState?.sort ?? DEFAULT_SORT);
|
||||
|
||||
// const state: Writable<FontCollectionState> = writable({
|
||||
// ...defaultState,
|
||||
// ...initialState,
|
||||
// });
|
||||
|
||||
// const isLoading = writable(false);
|
||||
let isLoading = $state<boolean>(false);
|
||||
// const error = writable<string | undefined>();
|
||||
let error = $state<string | undefined>();
|
||||
|
||||
// Derived store for fonts as array
|
||||
// const fonts = derived(state, $state => {
|
||||
// return Object.values($state.fonts);
|
||||
// });
|
||||
//
|
||||
|
||||
const filtrationArray = $derived([
|
||||
(font: UnifiedFont) => filters.providers?.includes(font.provider),
|
||||
(font: UnifiedFont) => filters.categories?.includes(font.category),
|
||||
(font: UnifiedFont) => filters.subsets?.some(subset => font.subsets.includes(subset)),
|
||||
(font: UnifiedFont) =>
|
||||
filters.searchQuery
|
||||
? font.name.toLowerCase().includes(filters.searchQuery.toLowerCase())
|
||||
: true,
|
||||
]);
|
||||
|
||||
const filteredFonts = $derived(
|
||||
Object.values(fonts).filter(font => {
|
||||
return filtrationArray.every(filter => filter(font));
|
||||
}),
|
||||
);
|
||||
|
||||
// Derived store for filtered fonts
|
||||
// const filteredFonts = derived([state, fonts], ([$state, $fonts]) => {
|
||||
// let filtered = [...$fonts];
|
||||
|
||||
// // Apply search filter
|
||||
// if ($state.filters.searchQuery) {
|
||||
// const query = $state.filters.searchQuery.toLowerCase();
|
||||
// filtered = filtered.filter(font => font.name.toLowerCase().includes(query));
|
||||
// }
|
||||
|
||||
// // Apply provider filter
|
||||
// if ($state.filters.provider) {
|
||||
// filtered = filtered.filter(
|
||||
// font => font.provider === $state.filters.provider,
|
||||
// );
|
||||
// }
|
||||
|
||||
// // Apply category filter
|
||||
// if ($state.filters.category) {
|
||||
// filtered = filtered.filter(
|
||||
// font => font.category === $state.filters.category,
|
||||
// );
|
||||
// }
|
||||
|
||||
// // Apply subset filter
|
||||
// if ($state.filters.subsets?.length) {
|
||||
// filtered = filtered.filter(font =>
|
||||
// $state.filters.subsets!.some(subset => font.subsets.includes(subset as FontSubset))
|
||||
// );
|
||||
// }
|
||||
|
||||
// // Apply sort
|
||||
// const { field, direction } = $state.sort;
|
||||
// const multiplier = direction === 'asc' ? 1 : -1;
|
||||
|
||||
// filtered.sort((a, b) => {
|
||||
// let comparison = 0;
|
||||
|
||||
// if (field === 'name') {
|
||||
// comparison = a.name.localeCompare(b.name);
|
||||
// } else if (field === 'popularity') {
|
||||
// const aPop = a.metadata.popularity ?? 0;
|
||||
// const bPop = b.metadata.popularity ?? 0;
|
||||
// comparison = aPop - bPop;
|
||||
// } else if (field === 'category') {
|
||||
// comparison = a.category.localeCompare(b.category);
|
||||
// }
|
||||
|
||||
// return comparison * multiplier;
|
||||
// });
|
||||
|
||||
// return filtered;
|
||||
// });
|
||||
|
||||
// Derived store for count
|
||||
// const count = derived(fonts, $fonts => $fonts.length);
|
||||
|
||||
const count = $derived(fonts.size);
|
||||
|
||||
return {
|
||||
get fonts() {
|
||||
return fonts;
|
||||
},
|
||||
set fonts(newFonts) {
|
||||
fonts = newFonts;
|
||||
},
|
||||
get filteredFonts() {
|
||||
return filteredFonts;
|
||||
},
|
||||
get filters() {
|
||||
return filters;
|
||||
},
|
||||
set filters(newFilters) {
|
||||
filters = newFilters;
|
||||
},
|
||||
get searchQuery() {
|
||||
return filters.searchQuery;
|
||||
},
|
||||
set searchQuery(newSearchQuery) {
|
||||
filters = {
|
||||
...filters,
|
||||
searchQuery: newSearchQuery,
|
||||
};
|
||||
},
|
||||
get sort() {
|
||||
return sort;
|
||||
},
|
||||
set sort(newSort) {
|
||||
sort = newSort;
|
||||
},
|
||||
get count() {
|
||||
return count;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
set isLoading(value) {
|
||||
isLoading = value;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
set error(value) {
|
||||
error = value;
|
||||
},
|
||||
addFonts(newFonts: UnifiedFont[]) {
|
||||
fonts = newFonts.reduce((acc, font) => {
|
||||
cache.set(font.id, font);
|
||||
return { ...acc, [font.id]: font };
|
||||
}, fonts);
|
||||
},
|
||||
addFont(font: UnifiedFont) {
|
||||
cache.set(font.id, font);
|
||||
fonts = Object.fromEntries(Object.entries(fonts).concat([[font.id, font]]));
|
||||
},
|
||||
removeFont(fontId: string) {
|
||||
cache.remove(fontId);
|
||||
fonts = Object.fromEntries(Object.entries(fonts).filter(([id]) => id !== fontId));
|
||||
},
|
||||
clearFonts() {
|
||||
fonts = {};
|
||||
cache.clear();
|
||||
},
|
||||
clearFilters() {
|
||||
filters = {
|
||||
searchQuery: '',
|
||||
providers: [],
|
||||
categories: [],
|
||||
};
|
||||
},
|
||||
clearSort() {
|
||||
sort = DEFAULT_SORT;
|
||||
},
|
||||
getFontById(fontId: string) {
|
||||
return fonts[fontId];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type FontCollectionStore = ReturnType<typeof createFontCollection>;
|
||||
@@ -1,194 +0,0 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from '../model/types/common';
|
||||
import {
|
||||
filterValidValues,
|
||||
isValidFontCategory,
|
||||
isValidFontFilters,
|
||||
isValidFontProvider,
|
||||
isValidFontSubset,
|
||||
validateFilterValues,
|
||||
} from './typeGuards';
|
||||
|
||||
describe('typeGuards', () => {
|
||||
describe('isValidFontProvider', () => {
|
||||
it('should return true for valid providers', () => {
|
||||
expect(isValidFontProvider('google')).toBe(true);
|
||||
expect(isValidFontProvider('fontshare')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid providers', () => {
|
||||
expect(isValidFontProvider('invalid')).toBe(false);
|
||||
expect(isValidFontProvider('Google')).toBe(false); // Case-sensitive
|
||||
expect(isValidFontProvider('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidFontCategory', () => {
|
||||
it('should return true for valid categories', () => {
|
||||
expect(isValidFontCategory('serif')).toBe(true);
|
||||
expect(isValidFontCategory('sans-serif')).toBe(true);
|
||||
expect(isValidFontCategory('display')).toBe(true);
|
||||
expect(isValidFontCategory('handwriting')).toBe(true);
|
||||
expect(isValidFontCategory('monospace')).toBe(true);
|
||||
expect(isValidFontCategory('script')).toBe(true);
|
||||
expect(isValidFontCategory('slab')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid categories', () => {
|
||||
expect(isValidFontCategory('invalid')).toBe(false);
|
||||
expect(isValidFontCategory('Serif')).toBe(false); // Case-sensitive
|
||||
expect(isValidFontCategory('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidFontSubset', () => {
|
||||
it('should return true for valid subsets', () => {
|
||||
expect(isValidFontSubset('latin')).toBe(true);
|
||||
expect(isValidFontSubset('latin-ext')).toBe(true);
|
||||
expect(isValidFontSubset('cyrillic')).toBe(true);
|
||||
expect(isValidFontSubset('greek')).toBe(true);
|
||||
expect(isValidFontSubset('arabic')).toBe(true);
|
||||
expect(isValidFontSubset('devanagari')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid subsets', () => {
|
||||
expect(isValidFontSubset('invalid')).toBe(false);
|
||||
expect(isValidFontSubset('Latin')).toBe(false); // Case-sensitive
|
||||
expect(isValidFontSubset('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFilterValues', () => {
|
||||
it('should return true when all values are valid providers', () => {
|
||||
const values = ['google', 'fontshare'];
|
||||
expect(validateFilterValues(values, isValidFontProvider)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when all values are valid categories', () => {
|
||||
const values = ['serif', 'sans-serif', 'display'];
|
||||
expect(validateFilterValues(values, isValidFontCategory)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when any value is invalid', () => {
|
||||
const values = ['serif', 'invalid-category'];
|
||||
expect(validateFilterValues(values, isValidFontCategory)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for empty arrays', () => {
|
||||
const values: string[] = [];
|
||||
expect(validateFilterValues(values, isValidFontProvider)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterValidValues', () => {
|
||||
it('should filter out invalid values', () => {
|
||||
const values = ['google', 'invalid', 'fontshare', 'another-invalid'];
|
||||
const result = filterValidValues(values, isValidFontProvider);
|
||||
|
||||
expect(result).toEqual(['google', 'fontshare']);
|
||||
});
|
||||
|
||||
it('should return empty array when no values are valid', () => {
|
||||
const values = ['invalid1', 'invalid2'];
|
||||
const result = filterValidValues(values, isValidFontProvider);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all values when all are valid', () => {
|
||||
const values = ['serif', 'sans-serif'];
|
||||
const result = filterValidValues(values, isValidFontCategory);
|
||||
|
||||
expect(result).toEqual(['serif', 'sans-serif']);
|
||||
});
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
const result = filterValidValues([], isValidFontProvider);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidFontFilters', () => {
|
||||
it('should return true for valid filter object', () => {
|
||||
const filters = {
|
||||
providers: ['google', 'fontshare'],
|
||||
categories: ['serif', 'sans-serif'],
|
||||
subsets: ['latin'],
|
||||
};
|
||||
|
||||
expect(isValidFontFilters(filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when providers are invalid', () => {
|
||||
const filters = {
|
||||
providers: ['invalid'],
|
||||
categories: ['serif'],
|
||||
subsets: ['latin'],
|
||||
};
|
||||
|
||||
expect(isValidFontFilters(filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when categories are invalid', () => {
|
||||
const filters = {
|
||||
providers: ['google'],
|
||||
categories: ['invalid'],
|
||||
subsets: ['latin'],
|
||||
};
|
||||
|
||||
expect(isValidFontFilters(filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when subsets are invalid', () => {
|
||||
const filters = {
|
||||
providers: ['google'],
|
||||
categories: ['serif'],
|
||||
subsets: ['invalid'],
|
||||
};
|
||||
|
||||
expect(isValidFontFilters(filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for empty arrays', () => {
|
||||
const filters = {
|
||||
providers: [],
|
||||
categories: [],
|
||||
subsets: [],
|
||||
};
|
||||
|
||||
expect(isValidFontFilters(filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should type narrow correctly for TypeScript', () => {
|
||||
const filters: {
|
||||
providers: string[];
|
||||
categories: string[];
|
||||
subsets: string[];
|
||||
} = {
|
||||
providers: ['google', 'fontshare'],
|
||||
categories: ['serif'],
|
||||
subsets: ['latin'],
|
||||
};
|
||||
|
||||
if (isValidFontFilters(filters)) {
|
||||
// TypeScript should know these are now typed arrays
|
||||
const provider: FontProvider = filters.providers[0]!;
|
||||
const category: FontCategory = filters.categories[0]!;
|
||||
const subset: FontSubset = filters.subsets[0]!;
|
||||
|
||||
expect(provider).toBe('google');
|
||||
expect(category).toBe('serif');
|
||||
expect(subset).toBe('latin');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* FONT TYPE GUARDS
|
||||
* ============================================================================
|
||||
*
|
||||
* Runtime type validation utilities for font-related types.
|
||||
* These type guards validate filter values to ensure type safety
|
||||
* when bridging between UI components and the store.
|
||||
*/
|
||||
|
||||
import {
|
||||
FONT_CATEGORIES,
|
||||
FONT_PROVIDERS,
|
||||
FONT_SUBSETS,
|
||||
} from '$features/FilterFonts/model/const/const';
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from '../model/types/common';
|
||||
|
||||
/**
|
||||
* Type guard for font providers
|
||||
*
|
||||
* @param value - The value to validate
|
||||
* @returns True if the value is a valid FontProvider
|
||||
*/
|
||||
export function isValidFontProvider(value: string): value is FontProvider {
|
||||
return FONT_PROVIDERS.some(p => p.value === value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for font categories
|
||||
*
|
||||
* @param value - The value to validate
|
||||
* @returns True if the value is a valid FontCategory
|
||||
*/
|
||||
export function isValidFontCategory(value: string): value is FontCategory {
|
||||
return FONT_CATEGORIES.some(c => c.value === value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for font subsets
|
||||
*
|
||||
* @param value - The value to validate
|
||||
* @returns True if the value is a valid FontSubset
|
||||
*/
|
||||
export function isValidFontSubset(value: string): value is FontSubset {
|
||||
return FONT_SUBSETS.some(s => s.value === value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate array of filter values using a type guard
|
||||
*
|
||||
* @param values - The array of values to validate
|
||||
* @param validator - The type guard function to use for validation
|
||||
* @returns True if all values pass the type guard
|
||||
*/
|
||||
export function validateFilterValues<T extends string>(
|
||||
values: string[],
|
||||
validator: (value: string) => value is T,
|
||||
): values is T[] {
|
||||
return values.every(validator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and filter an array of values, keeping only valid ones
|
||||
*
|
||||
* @param values - The array of values to filter
|
||||
* @param validator - The type guard function to use for validation
|
||||
* @returns Array containing only valid values
|
||||
*/
|
||||
export function filterValidValues<T extends string>(
|
||||
values: string[],
|
||||
validator: (value: string) => value is T,
|
||||
): T[] {
|
||||
return values.filter(validator) as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for FontFilters object
|
||||
*
|
||||
* @param filters - The filters object to validate
|
||||
* @returns True if all filter values are valid
|
||||
*/
|
||||
export function isValidFontFilters(filters: {
|
||||
providers: string[];
|
||||
categories: string[];
|
||||
subsets: string[];
|
||||
}): filters is {
|
||||
providers: FontProvider[];
|
||||
categories: FontCategory[];
|
||||
subsets: FontSubset[];
|
||||
} {
|
||||
return (
|
||||
validateFilterValues(filters.providers, isValidFontProvider)
|
||||
&& validateFilterValues(filters.categories, isValidFontCategory)
|
||||
&& validateFilterValues(filters.subsets, isValidFontSubset)
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { UnifiedFont } from '../types/normalize';
|
||||
|
||||
export function createMockStore() {
|
||||
let fonts = $state<UnifiedFont[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let isFetching = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
return {
|
||||
get fonts() {
|
||||
console.log('MockStore: get fonts', fonts.length);
|
||||
return fonts;
|
||||
},
|
||||
set fonts(v) {
|
||||
console.log('MockStore: set fonts', v.length);
|
||||
fonts = v;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
set isLoading(v) {
|
||||
isLoading = v;
|
||||
},
|
||||
get isFetching() {
|
||||
return isFetching;
|
||||
},
|
||||
set isFetching(v) {
|
||||
isFetching = v;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
set error(v) {
|
||||
error = v;
|
||||
},
|
||||
setParams: vi.fn(),
|
||||
clearCache: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* Unit tests for unified font store
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
} from '$entities/Font/model/types/common';
|
||||
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
// Import reactive mock factory
|
||||
import { tick } from 'svelte';
|
||||
import { createMockStore } from './mocks.svelte';
|
||||
|
||||
// Mock the service stores BEFORE importing the unified store
|
||||
// We use the reactive mock factory
|
||||
const mockGoogleFonts = createMockStore();
|
||||
const mockFontshareFonts = createMockStore();
|
||||
|
||||
vi.mock('../../services/fetchGoogleFonts.svelte', () => ({
|
||||
createGoogleFontsStore: () => mockGoogleFonts,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/fetchFontshareFonts.svelte', () => ({
|
||||
createFontshareStore: () => mockFontshareFonts,
|
||||
}));
|
||||
|
||||
// Import store after mocking
|
||||
// Import factory and type
|
||||
import {
|
||||
type UnifiedFontStore,
|
||||
createUnifiedFontStore,
|
||||
} from '../unifiedFontStore.svelte';
|
||||
|
||||
// Mock UnifiedFont for testing
|
||||
const mockFont = (
|
||||
id: string,
|
||||
name: string,
|
||||
provider: FontProvider,
|
||||
category: FontCategory,
|
||||
): UnifiedFont => ({
|
||||
id,
|
||||
name,
|
||||
provider,
|
||||
category,
|
||||
subsets: ['latin'],
|
||||
variants: ['regular'],
|
||||
styles: {
|
||||
regular: 'https://example.com/font.woff2',
|
||||
},
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
popularity: 100,
|
||||
},
|
||||
features: {
|
||||
isVariable: false,
|
||||
},
|
||||
});
|
||||
|
||||
describe('Unified Font Store', () => {
|
||||
let unifiedFontStore: UnifiedFontStore;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock data
|
||||
mockGoogleFonts.fonts = [];
|
||||
mockFontshareFonts.fonts = [];
|
||||
mockGoogleFonts.isLoading = false;
|
||||
mockFontshareFonts.isLoading = false;
|
||||
mockGoogleFonts.error = null;
|
||||
mockFontshareFonts.error = null;
|
||||
|
||||
// Create fresh store instance
|
||||
unifiedFontStore = createUnifiedFontStore();
|
||||
unifiedFontStore.clearFilters();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock data
|
||||
mockGoogleFonts.fonts = [];
|
||||
mockFontshareFonts.fonts = [];
|
||||
mockGoogleFonts.isLoading = false;
|
||||
mockFontshareFonts.isLoading = false;
|
||||
mockGoogleFonts.error = null;
|
||||
mockFontshareFonts.error = null;
|
||||
});
|
||||
|
||||
describe('Debug Identity', () => {
|
||||
it('uses the correct mock instance', () => {
|
||||
// @ts-ignore
|
||||
expect(unifiedFontStore.providers.google).toBe(mockGoogleFonts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Aggregation', () => {
|
||||
it('aggregates fonts from both providers', async () => {
|
||||
const googleFont = mockFont('g1', 'Google Font', 'google', 'sans-serif');
|
||||
const fontshareFont = mockFont('f1', 'Fontshare Font', 'fontshare', 'serif');
|
||||
|
||||
mockGoogleFonts.fonts = [googleFont];
|
||||
mockFontshareFonts.fonts = [fontshareFont];
|
||||
|
||||
// Wait for reactivity (Svelte 5 updates are batched)
|
||||
await tick();
|
||||
|
||||
// Trigger reactivity updates if needed (Store getters compute on access)
|
||||
expect(unifiedFontStore.count).toBe(2);
|
||||
expect(unifiedFontStore.fonts).toContain(googleFont);
|
||||
expect(unifiedFontStore.fonts).toContain(fontshareFont);
|
||||
});
|
||||
|
||||
it('handles empty states', () => {
|
||||
expect(unifiedFontStore.count).toBe(0);
|
||||
});
|
||||
|
||||
it('aggregates loading state', async () => {
|
||||
mockGoogleFonts.isLoading = true;
|
||||
await tick();
|
||||
expect(unifiedFontStore.isLoading).toBe(true);
|
||||
|
||||
mockGoogleFonts.isLoading = false;
|
||||
mockFontshareFonts.isLoading = true;
|
||||
await tick();
|
||||
expect(unifiedFontStore.isLoading).toBe(true);
|
||||
|
||||
mockFontshareFonts.isLoading = false;
|
||||
await tick();
|
||||
expect(unifiedFontStore.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filter Management', () => {
|
||||
it('sets and applies provider filter', async () => {
|
||||
const googleFont = mockFont('g1', 'Google Font', 'google', 'sans-serif');
|
||||
const fontshareFont = mockFont('f1', 'Fontshare Font', 'fontshare', 'serif');
|
||||
|
||||
mockGoogleFonts.fonts = [googleFont];
|
||||
mockFontshareFonts.fonts = [fontshareFont];
|
||||
await tick();
|
||||
|
||||
// Filter to Google only
|
||||
unifiedFontStore.setFilter('providers', ['google']);
|
||||
|
||||
expect(unifiedFontStore.filteredFonts.length).toBe(1);
|
||||
expect(unifiedFontStore.filteredFonts[0].id).toBe('g1');
|
||||
|
||||
// Filter to Fontshare only
|
||||
unifiedFontStore.setFilter('providers', ['fontshare']);
|
||||
expect(unifiedFontStore.filteredFonts.length).toBe(1);
|
||||
expect(unifiedFontStore.filteredFonts[0].id).toBe('f1');
|
||||
});
|
||||
|
||||
it('sets search query and filters', async () => {
|
||||
const font1 = mockFont('1', 'Roboto', 'google', 'sans-serif');
|
||||
const font2 = mockFont('2', 'Open Sans', 'google', 'sans-serif');
|
||||
|
||||
mockGoogleFonts.fonts = [font1, font2];
|
||||
await tick();
|
||||
|
||||
unifiedFontStore.searchQuery = 'Robo';
|
||||
|
||||
expect(unifiedFontStore.filteredFonts.length).toBe(1);
|
||||
expect(unifiedFontStore.filteredFonts[0].name).toBe('Roboto');
|
||||
});
|
||||
|
||||
it('clears all filters', () => {
|
||||
unifiedFontStore.setFilter('providers', ['google']);
|
||||
unifiedFontStore.searchQuery = 'Test';
|
||||
|
||||
unifiedFontStore.clearFilters();
|
||||
|
||||
expect(unifiedFontStore.filters.providers).toEqual([]);
|
||||
expect(unifiedFontStore.searchQuery).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interaction with Services', () => {
|
||||
it('fetchFonts calls setParams on underlying stores', async () => {
|
||||
await unifiedFontStore.fetchFonts({
|
||||
search: 'test',
|
||||
categories: ['serif'],
|
||||
});
|
||||
|
||||
expect(mockGoogleFonts.setParams).toHaveBeenCalledWith(expect.objectContaining({
|
||||
category: 'serif',
|
||||
}));
|
||||
|
||||
// Verify local state update as well
|
||||
expect(unifiedFontStore.searchQuery).toBe('test');
|
||||
});
|
||||
|
||||
it('clearCache calls clearCache on underlying stores', () => {
|
||||
unifiedFontStore.clearCache();
|
||||
expect(mockGoogleFonts.clearCache).toHaveBeenCalled();
|
||||
expect(mockFontshareFonts.clearCache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
import { createFontCollection } from '../../lib';
|
||||
|
||||
export const fontCollection = createFontCollection();
|
||||
@@ -1,115 +0,0 @@
|
||||
import {
|
||||
filterValidValues,
|
||||
isValidFontCategory,
|
||||
isValidFontProvider,
|
||||
isValidFontSubset,
|
||||
} from '$entities/Font/lib/typeGuards';
|
||||
import type { UnifiedFontStore } from '$entities/Font/model/store';
|
||||
import { filterManager } from '$features/FilterFonts';
|
||||
import type { Property } from '$shared/lib';
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* FILTER BRIDGE
|
||||
* ============================================================================
|
||||
*
|
||||
* Bridges the UI filter state (filterManager from FilterFonts feature) with the
|
||||
* unified font store (unifiedFontStore from Font entity).
|
||||
*
|
||||
* OPTIMIZATIONS (P1):
|
||||
* - Runtime type validation using type guards
|
||||
* - No more 'as any[]' casts - type-safe assignments
|
||||
*/
|
||||
|
||||
// ... (comments)
|
||||
|
||||
// ============================================================================
|
||||
// FILTER GROUP MAPPING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get selected values from a filter manager group by ID
|
||||
*/
|
||||
function getSelectedFilterValues(groupId: string): string[] {
|
||||
const group = filterManager.getGroup(groupId);
|
||||
if (!group) return [];
|
||||
|
||||
return group.instance.selectedProperties.map((property: Property<string>) => property.value);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SYNC LOGIC
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Sync filter manager state to unified font store
|
||||
*
|
||||
* @param store - The unified font store instance
|
||||
*/
|
||||
export function syncFilters(store: UnifiedFontStore): void {
|
||||
const providers = getSelectedFilterValues('providers');
|
||||
const categories = getSelectedFilterValues('categories');
|
||||
const subsets = getSelectedFilterValues('subsets');
|
||||
|
||||
// Validate and filter providers
|
||||
const validProviders = filterValidValues(providers, isValidFontProvider);
|
||||
|
||||
// Validate and filter categories
|
||||
const validCategories = filterValidValues(categories, isValidFontCategory);
|
||||
|
||||
// Validate and filter subsets
|
||||
const validSubsets = filterValidValues(subsets, isValidFontSubset);
|
||||
|
||||
// Update unified store filters with validated, type-safe values
|
||||
store.filters = {
|
||||
providers: validProviders,
|
||||
categories: validCategories,
|
||||
subsets: validSubsets,
|
||||
searchQuery: store.filters.searchQuery,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Apply current filters and fetch fonts from providers
|
||||
*
|
||||
* @param store - The unified font store instance
|
||||
*/
|
||||
export async function applyFilters(store: UnifiedFontStore): Promise<void> {
|
||||
await store.fetchFonts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all filters to their default state
|
||||
*
|
||||
* @param store - The unified font store instance
|
||||
*/
|
||||
export function resetFilters(store: UnifiedFontStore): void {
|
||||
// Reset filter manager selections
|
||||
filterManager.deselectAllGlobal();
|
||||
|
||||
// Clear unified store filters
|
||||
store.clearFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply current filter selections and fetch fonts
|
||||
*
|
||||
* @param store - The unified font store instance
|
||||
*/
|
||||
export async function applyFilterType(store: UnifiedFontStore): Promise<void> {
|
||||
syncFilters(store);
|
||||
await store.fetchFonts();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RE-EXPORTS FOR CONVENIENCE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Re-export filter manager for direct access
|
||||
*/
|
||||
export { filterManager } from '$features/FilterFonts';
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Generic collection API response model
|
||||
* Use this for APIs that return collections of items
|
||||
*
|
||||
* @template T - The type of items in the collection array
|
||||
* @template K - The key used to access the collection array in the response
|
||||
*/
|
||||
export type CollectionApiModel<T, K extends string = 'items'> = Record<K, T[]> & {
|
||||
/**
|
||||
* Number of items returned in the current page/response
|
||||
*/
|
||||
count: number;
|
||||
/**
|
||||
* Total number of items available across all pages
|
||||
*/
|
||||
count_total: number;
|
||||
/**
|
||||
* Indicates if there are more items available beyond this page
|
||||
*/
|
||||
has_more: boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user