diff --git a/src/entities/Font/lib/filterUtils.test.ts b/src/entities/Font/lib/filterUtils.test.ts new file mode 100644 index 0000000..52b957b --- /dev/null +++ b/src/entities/Font/lib/filterUtils.test.ts @@ -0,0 +1,297 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import type { UnifiedFont } from '../model/types/normalize'; +import { + filterFonts, + sortFonts, +} from './filterUtils'; + +const createMockFont = (overrides: Partial = {}): 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 + }); + }); +}); diff --git a/src/entities/Font/lib/filterUtils.ts b/src/entities/Font/lib/filterUtils.ts new file mode 100644 index 0000000..07b1acb --- /dev/null +++ b/src/entities/Font/lib/filterUtils.ts @@ -0,0 +1,100 @@ +/** + * ============================================================================ + * 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; + } + }); +} diff --git a/src/entities/Font/lib/index.ts b/src/entities/Font/lib/index.ts index add6359..6a71073 100644 --- a/src/entities/Font/lib/index.ts +++ b/src/entities/Font/lib/index.ts @@ -2,3 +2,17 @@ export { createFontCollection, type FontCollectionStore, } from './helpers/createFontCollection.svelte'; + +export { + filterFonts, + sortFonts, +} from './filterUtils'; + +export { + filterValidValues, + isValidFontCategory, + isValidFontFilters, + isValidFontProvider, + isValidFontSubset, + validateFilterValues, +} from './typeGuards'; diff --git a/src/entities/Font/lib/typeGuards.test.ts b/src/entities/Font/lib/typeGuards.test.ts new file mode 100644 index 0000000..4190c1e --- /dev/null +++ b/src/entities/Font/lib/typeGuards.test.ts @@ -0,0 +1,194 @@ +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'); + } + }); + }); +}); diff --git a/src/entities/Font/lib/typeGuards.ts b/src/entities/Font/lib/typeGuards.ts new file mode 100644 index 0000000..5b0878c --- /dev/null +++ b/src/entities/Font/lib/typeGuards.ts @@ -0,0 +1,100 @@ +/** + * ============================================================================ + * 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( + 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( + 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) + ); +} diff --git a/src/entities/Font/model/store/__tests__/mocks.svelte.ts b/src/entities/Font/model/store/__tests__/mocks.svelte.ts new file mode 100644 index 0000000..32b3e67 --- /dev/null +++ b/src/entities/Font/model/store/__tests__/mocks.svelte.ts @@ -0,0 +1,41 @@ +import { vi } from 'vitest'; +import type { UnifiedFont } from '../types/normalize'; + +export function createMockStore() { + let fonts = $state([]); + let isLoading = $state(false); + let isFetching = $state(false); + let error = $state(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(), + }; +} diff --git a/src/entities/Font/model/store/__tests__/unifiedFontStore.test.ts b/src/entities/Font/model/store/__tests__/unifiedFontStore.test.ts new file mode 100644 index 0000000..bb67131 --- /dev/null +++ b/src/entities/Font/model/store/__tests__/unifiedFontStore.test.ts @@ -0,0 +1,202 @@ +/** + * 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(); + }); + }); +}); diff --git a/src/entities/Font/model/store/unifiedFontStore.svelte.ts b/src/entities/Font/model/store/unifiedFontStore.svelte.ts new file mode 100644 index 0000000..b767e04 --- /dev/null +++ b/src/entities/Font/model/store/unifiedFontStore.svelte.ts @@ -0,0 +1,263 @@ +/** + * ============================================================================ + * UNIFIED FONT STORE + * ============================================================================ + * + * Single source of truth for font operations across all providers. + * Combines fetching, filtering, caching, and managing fonts from Google Fonts and Fontshare. + * + * NOW INTEGRATED WITH TANSTACK QUERY via specific stores. + * + * OPTIMIZATIONS (P0/P1): + * - Debounced search (300ms) to reduce re-renders + * - Single-pass filter function for O(n) complexity + * - Two derived values: filteredFonts (filtered) + sortedFilteredFonts (final) + * - TanStack Query cancellation for stale requests + */ + +import { debounce } from '$shared/lib/utils'; +import { + filterFonts, + sortFonts, +} from '../../lib/filterUtils'; +import { createFontshareStore } from '../services/fetchFontshareFonts.svelte'; +import { createGoogleFontsStore } from '../services/fetchGoogleFonts.svelte'; +import type { UnifiedFont } from '../types/normalize'; +import type { + FetchFontsParams, + FilterType, + FontFilters, + FontSort, +} from './types'; + +/** + * Creates a unified font store instance. + */ +export function createUnifiedFontStore() { + // Instantiate specific stores + const googleStore = createGoogleFontsStore(); + const fontshareStore = createFontshareStore(); + + // Internal state for local filters (that apply to the combined list) + let _filters = $state({ + providers: [], + categories: [], + subsets: [], + searchQuery: '', + }); + + let _sort = $state({ field: 'name', direction: 'asc' }); + + // === Debounced Search === + // Debounce search query to avoid excessive filtering during typing + let _debouncedSearchQuery = $state(''); + + const setSearchQuery = debounce((query: string) => { + _debouncedSearchQuery = query; + }, 300); + + $effect(() => { + setSearchQuery(_filters.searchQuery); + }); + + // === Computed Values === + + /** + * Combined fonts from all active stores + */ + const allFonts = $derived.by(() => { + let fonts: UnifiedFont[] = []; + + // Google Fonts + if (_filters.providers.length === 0 || _filters.providers.includes('google')) { + fonts = [...fonts, ...googleStore.fonts]; + } + + // Fontshare Fonts + if (_filters.providers.length === 0 || _filters.providers.includes('fontshare')) { + fonts = [...fonts, ...fontshareStore.fonts]; + } + + return fonts; + }); + + /** + * Filtered fonts (before sort) - Derived Value #1 + * Uses optimized single-pass filter function + */ + const filteredFonts = $derived.by(() => { + const filtersForFiltering: FontFilters = { + providers: _filters.providers, + categories: _filters.categories, + subsets: _filters.subsets, + searchQuery: _debouncedSearchQuery, + }; + return filterFonts(allFonts, filtersForFiltering); + }); + + /** + * Sorted filtered fonts (final result) - Derived Value #2 + * Fast path: skip sorting for default name ascending order + */ + const sortedFilteredFonts = $derived.by(() => { + const filtered = filteredFonts; + + // Fast path: default sort (name ascending) - already sorted by filterFonts + if (_sort.field === 'name' && _sort.direction === 'asc') { + return filtered; + } + + return sortFonts(filtered, _sort); + }); + + // === Status Derivation === + + const isLoading = $derived(googleStore.isLoading || fontshareStore.isLoading); + const isFetching = $derived(googleStore.isFetching || fontshareStore.isFetching); + const error = $derived(googleStore.error?.message || fontshareStore.error?.message || null); + + // === Methods === + + /** + * Fetch fonts from all active providers using TanStack Query + * This now mostly just updates params or triggers refetch if needed. + * + * Includes cancellation of stale requests to improve performance. + */ + async function fetchFonts(params?: FetchFontsParams): Promise { + // Update local filters + if (params) { + if (params.providers) _filters.providers = params.providers; + if (params.categories) _filters.categories = params.categories; + if (params.subsets) _filters.subsets = params.subsets; + if (params.search !== undefined) _filters.searchQuery = params.search; + if (params.sort) _sort = params.sort; + } + + // Cancel existing queries before starting new ones (optimization) + googleStore.cancel(); + fontshareStore.cancel(); + + // Trigger fetches in underlying stores + // We pass the filter params down to the stores so they can optimize server-side fetching if supported + // For Google Fonts: + googleStore.setParams({ + category: _filters.categories.length === 1 ? _filters.categories[0] : undefined, + subset: _filters.subsets.length === 1 ? _filters.subsets[0] : undefined, + sort: (_sort.field === 'popularity' + ? 'popularity' + : _sort.field === 'date' + ? 'date' + : 'alpha') as any, + }); + + // For Fontshare: + // fontshareStore.setCategories(_filters.categories); // If supported + } + + /** Update specific filter */ + function setFilter(type: FilterType, values: string[]): void { + if (type === 'searchQuery') { + _filters.searchQuery = values[0] ?? ''; + } else { + _filters = { + ..._filters, + [type]: values as any, // Type cast for loose array matching + }; + } + } + + /** Clear specific filter */ + function clearFilter(type: FilterType): void { + if (type === 'searchQuery') { + _filters.searchQuery = ''; + } else { + _filters = { + ..._filters, + [type]: [], + }; + } + } + + /** Clear all filters */ + function clearFilters(): void { + _filters = { + providers: [], + categories: [], + subsets: [], + searchQuery: '', + }; + } + + return { + // Getters + get fonts() { + return allFonts; + }, + get filteredFonts() { + return sortedFilteredFonts; + }, + get count() { + return allFonts.length; + }, + get isLoading() { + return isLoading; + }, + get isFetching() { + return isFetching; + }, + get error() { + return error; + }, + get filters() { + return _filters; + }, + get searchQuery() { + return _filters.searchQuery; + }, + get sort() { + return _sort; + }, + get providers() { + // Expose underlying stores for direct access if needed + return { + google: googleStore, + fontshare: fontshareStore, + }; + }, + + // Setters + set filters(value: FontFilters) { + _filters = value; + }, + set searchQuery(value: string) { + _filters.searchQuery = value; + }, + set sort(value: FontSort) { + _sort = value; + }, + + // Methods + fetchFonts, + setFilter, + clearFilter, + clearFilters, + + // Legacy support (no-op or adapted) + addFont: () => {}, // Not supported in reactive query model + addFonts: () => {}, + removeFont: () => {}, + getFontById: (id: string) => allFonts.find(f => f.id === id), + clearCache: () => { + googleStore.clearCache(); + fontshareStore.clearCache(); + }, + }; +} + +export type UnifiedFontStore = ReturnType; + +/** + * Context key for dependency injection + */ +export const UNIFIED_FONT_STORE_KEY = Symbol('UNIFIED_FONT_STORE'); diff --git a/src/features/FontManagement/lib/filterBridge.svelte.ts b/src/features/FontManagement/lib/filterBridge.svelte.ts new file mode 100644 index 0000000..ffe71d4 --- /dev/null +++ b/src/features/FontManagement/lib/filterBridge.svelte.ts @@ -0,0 +1,115 @@ +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) => 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 { + 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 { + syncFilters(store); + await store.fetchFonts(); +} + +// ============================================================================ +// RE-EXPORTS FOR CONVENIENCE +// ============================================================================ + +/** + * Re-export filter manager for direct access + */ +export { filterManager } from '$features/FilterFonts'; diff --git a/src/shared/lib/utils/debounce/debounce.test.ts b/src/shared/lib/utils/debounce/debounce.test.ts new file mode 100644 index 0000000..d473d12 --- /dev/null +++ b/src/shared/lib/utils/debounce/debounce.test.ts @@ -0,0 +1,77 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { debounce } from './debounce'; + +describe('debounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should delay execution by the specified wait time', () => { + const mockFn = vi.fn(); + const debounced = debounce(mockFn, 300); + + debounced('arg1', 'arg2'); + + expect(mockFn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(300); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2'); + }); + + it('should cancel previous invocation and restart timer on subsequent calls', () => { + const mockFn = vi.fn(); + const debounced = debounce(mockFn, 300); + + debounced('first'); + vi.advanceTimersByTime(100); + + debounced('second'); + vi.advanceTimersByTime(100); + + debounced('third'); + vi.advanceTimersByTime(300); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith('third'); + }); + + it('should handle rapid calls correctly', () => { + const mockFn = vi.fn(); + const debounced = debounce(mockFn, 300); + + debounced('1'); + vi.advanceTimersByTime(50); + debounced('2'); + vi.advanceTimersByTime(50); + debounced('3'); + vi.advanceTimersByTime(50); + debounced('4'); + vi.advanceTimersByTime(300); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith('4'); + }); + + it('should not execute if timer is cleared before wait time', () => { + const mockFn = vi.fn(); + const debounced = debounce(mockFn, 300); + + debounced('test'); + vi.advanceTimersByTime(200); + + expect(mockFn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/lib/utils/debounce/debounce.ts b/src/shared/lib/utils/debounce/debounce.ts new file mode 100644 index 0000000..58ed530 --- /dev/null +++ b/src/shared/lib/utils/debounce/debounce.ts @@ -0,0 +1,43 @@ +/** + * ============================================================================ + * DEBOUNCE UTILITY + * ============================================================================ + * + * Creates a debounced function that delays execution until after wait milliseconds + * have elapsed since the last time it was invoked. + * + * @example + * ```typescript + * const debouncedSearch = debounce((query: string) => { + * console.log('Searching for:', query); + * }, 300); + * + * debouncedSearch('hello'); + * debouncedSearch('hello world'); // Only this will execute after 300ms + * ``` + */ + +/** + * Creates a debounced version of a function + * + * @param fn - The function to debounce + * @param wait - The delay in milliseconds + * @returns A debounced function that will execute after the specified delay + */ +export function debounce any>( + fn: T, + wait: number, +): (...args: Parameters) => void { + let timeoutId: ReturnType | null = null; + + return (...args: Parameters) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + fn(...args); + timeoutId = null; + }, wait); + }; +} diff --git a/src/shared/lib/utils/debounce/index.ts b/src/shared/lib/utils/debounce/index.ts new file mode 100644 index 0000000..0dea177 --- /dev/null +++ b/src/shared/lib/utils/debounce/index.ts @@ -0,0 +1 @@ +export { debounce } from './debounce'; diff --git a/src/shared/lib/utils/index.ts b/src/shared/lib/utils/index.ts index e7a726f..d2efc54 100644 --- a/src/shared/lib/utils/index.ts +++ b/src/shared/lib/utils/index.ts @@ -8,5 +8,6 @@ export { type QueryParamValue, } from './buildQueryString/buildQueryString'; export { clampNumber } from './clampNumber/clampNumber'; +export { debounce } from './debounce/debounce'; export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces'; export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';