From b6494a8cb50310b37d42fad5f3f8432579a9b9c0 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 24 May 2026 17:49:26 +0300 Subject: [PATCH] test(GetFonts): cover filters and sortStore + nest each in its own dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export createSortStore and FiltersStore so per-test instances can be constructed without sharing singleton state. Add unit tests covering: - sortStore: default + custom init, display→API value mapping, set() idempotency, singleton shape - filters: empty initial state, fetch population, single-call dedup, error path, cached-fetch reuse across observers Group each store with its tests under its own directory to match the filterManager layout. --- src/features/GetFonts/model/index.ts | 4 +- .../GetFonts/model/store/bindings.svelte.ts | 4 +- .../store/{ => filters}/filters.svelte.ts | 2 +- .../model/store/filters/filters.test.ts | 116 ++++++++++++++++++ .../store/{ => sortStore}/sortStore.svelte.ts | 2 +- .../model/store/sortStore/sortStore.test.ts | 68 ++++++++++ 6 files changed, 190 insertions(+), 6 deletions(-) rename src/features/GetFonts/model/store/{ => filters}/filters.svelte.ts (99%) create mode 100644 src/features/GetFonts/model/store/filters/filters.test.ts rename src/features/GetFonts/model/store/{ => sortStore}/sortStore.svelte.ts (94%) create mode 100644 src/features/GetFonts/model/store/sortStore/sortStore.test.ts diff --git a/src/features/GetFonts/model/index.ts b/src/features/GetFonts/model/index.ts index f1b9d75..0c91b4f 100644 --- a/src/features/GetFonts/model/index.ts +++ b/src/features/GetFonts/model/index.ts @@ -17,7 +17,7 @@ export { * Low-level property selection store */ filtersStore, -} from './store/filters.svelte'; +} from './store/filters/filters.svelte'; /** * Main filter controller @@ -67,4 +67,4 @@ export { * Reactive store for the current sort selection */ sortStore, -} from './store/sortStore.svelte'; +} from './store/sortStore/sortStore.svelte'; diff --git a/src/features/GetFonts/model/store/bindings.svelte.ts b/src/features/GetFonts/model/store/bindings.svelte.ts index 84ef8bb..cf98c2a 100644 --- a/src/features/GetFonts/model/store/bindings.svelte.ts +++ b/src/features/GetFonts/model/store/bindings.svelte.ts @@ -13,8 +13,8 @@ import { fontStore } from '$entities/Font'; import { untrack } from 'svelte'; import { mapManagerToParams } from '../../lib/mapper/mapManagerToParams'; import { filterManager } from './filterManager/filterManager.svelte'; -import { filtersStore } from './filters.svelte'; -import { sortStore } from './sortStore.svelte'; +import { filtersStore } from './filters/filters.svelte'; +import { sortStore } from './sortStore/sortStore.svelte'; $effect.root(() => { /** diff --git a/src/features/GetFonts/model/store/filters.svelte.ts b/src/features/GetFonts/model/store/filters/filters.svelte.ts similarity index 99% rename from src/features/GetFonts/model/store/filters.svelte.ts rename to src/features/GetFonts/model/store/filters/filters.svelte.ts index 27e4aba..642ddc6 100644 --- a/src/features/GetFonts/model/store/filters.svelte.ts +++ b/src/features/GetFonts/model/store/filters/filters.svelte.ts @@ -31,7 +31,7 @@ import { * Fetches and caches filter metadata using fetchProxyFilters() * Provides reactive access to filter data */ -class FiltersStore { +export class FiltersStore { /** * TanStack Query result state */ diff --git a/src/features/GetFonts/model/store/filters/filters.test.ts b/src/features/GetFonts/model/store/filters/filters.test.ts new file mode 100644 index 0000000..5dad619 --- /dev/null +++ b/src/features/GetFonts/model/store/filters/filters.test.ts @@ -0,0 +1,116 @@ +import { queryClient } from '$shared/api/queryClient'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import * as filtersApi from '../../../api/filters/filters'; +import type { FilterMetadata } from '../../../api/filters/filters'; +import { FiltersStore } from './filters.svelte'; + +/** + * Build a minimal FilterMetadata fixture for tests. + */ +function metadata(id: string, optionValues: string[] = []): FilterMetadata { + return { + id, + name: id, + description: '', + type: 'enum', + options: optionValues.map(value => ({ + id: value, + name: value, + value, + count: 1, + })), + } as FilterMetadata; +} + +describe('FiltersStore', () => { + let store: FiltersStore; + + beforeEach(() => { + queryClient.clear(); + // TanStack defaults retry=3 with exponential backoff, which would + // make the error-path test wait >5s. Disable for deterministic timing. + queryClient.setDefaultOptions({ queries: { retry: false } }); + vi.clearAllMocks(); + }); + + afterEach(() => { + store?.destroy(); + }); + + describe('initial state', () => { + it('starts with an empty filter list', () => { + vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]); + store = new FiltersStore(); + expect(store.filters).toEqual([]); + }); + + it('reports null error before any failure', () => { + vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]); + store = new FiltersStore(); + expect(store.error).toBeNull(); + }); + }); + + describe('successful fetch', () => { + it('populates filters with the fetched metadata', async () => { + const data = [ + metadata('providers', ['google', 'fontshare']), + metadata('categories', ['serif', 'sans-serif']), + ]; + vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue(data); + + store = new FiltersStore(); + + await vi.waitFor(() => expect(store.filters).toEqual(data), { timeout: 1000 }); + expect(store.isError).toBe(false); + expect(store.error).toBeNull(); + }); + + it('calls fetchProxyFilters exactly once for the initial load', async () => { + const spy = vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue([]); + store = new FiltersStore(); + + await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1), { timeout: 1000 }); + }); + }); + + describe('error handling', () => { + it('flips isError and exposes the error message on fetch failure', async () => { + vi.spyOn(filtersApi, 'fetchProxyFilters').mockRejectedValue(new Error('boom')); + store = new FiltersStore(); + + await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 }); + expect(store.error).toBe('boom'); + expect(store.filters).toEqual([]); + }); + }); + + describe('caching', () => { + it('does not trigger a second fetch when another instance shares the query key', async () => { + const data = [metadata('providers', ['google'])]; + const spy = vi.spyOn(filtersApi, 'fetchProxyFilters').mockResolvedValue(data); + + store = new FiltersStore(); + await vi.waitFor(() => expect(store.filters).toEqual(data), { timeout: 1000 }); + expect(spy).toHaveBeenCalledTimes(1); + + // A second observer on the same query key should reuse the cached + // result rather than triggering a new request. + const second = new FiltersStore(); + try { + // Give the new observer a tick to potentially refetch (it shouldn't). + await new Promise(r => setTimeout(r, 50)); + expect(spy).toHaveBeenCalledTimes(1); + } finally { + second.destroy(); + } + }); + }); +}); diff --git a/src/features/GetFonts/model/store/sortStore.svelte.ts b/src/features/GetFonts/model/store/sortStore/sortStore.svelte.ts similarity index 94% rename from src/features/GetFonts/model/store/sortStore.svelte.ts rename to src/features/GetFonts/model/store/sortStore/sortStore.svelte.ts index 0a090c5..3a8908f 100644 --- a/src/features/GetFonts/model/store/sortStore.svelte.ts +++ b/src/features/GetFonts/model/store/sortStore/sortStore.svelte.ts @@ -17,7 +17,7 @@ export const SORT_MAP: Record(initial); return { diff --git a/src/features/GetFonts/model/store/sortStore/sortStore.test.ts b/src/features/GetFonts/model/store/sortStore/sortStore.test.ts new file mode 100644 index 0000000..fcdc41b --- /dev/null +++ b/src/features/GetFonts/model/store/sortStore/sortStore.test.ts @@ -0,0 +1,68 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import { + SORT_MAP, + SORT_OPTIONS, + type SortOption, + createSortStore, + sortStore, +} from './sortStore.svelte'; + +describe('createSortStore', () => { + describe('initialization', () => { + it('defaults to Popularity when no initial value is provided', () => { + const store = createSortStore(); + expect(store.value).toBe('Popularity'); + }); + + it('accepts an explicit initial value', () => { + const store = createSortStore('Newest'); + expect(store.value).toBe('Newest'); + }); + }); + + describe('apiValue mapping', () => { + it.each<[SortOption, (typeof SORT_MAP)[SortOption]]>([ + ['Name', 'name'], + ['Popularity', 'popularity'], + ['Newest', 'lastModified'], + ])('maps %s to %s', (display, api) => { + const store = createSortStore(display); + expect(store.apiValue).toBe(api); + }); + }); + + describe('set()', () => { + it('updates both value and apiValue together', () => { + const store = createSortStore('Name'); + store.set('Newest'); + expect(store.value).toBe('Newest'); + expect(store.apiValue).toBe('lastModified'); + }); + + it('is idempotent — setting the current value keeps state consistent', () => { + const store = createSortStore('Popularity'); + store.set('Popularity'); + expect(store.value).toBe('Popularity'); + }); + }); +}); + +describe('sortStore singleton', () => { + it('exposes the same shape as a factory instance', () => { + expect(typeof sortStore.value).toBe('string'); + expect(typeof sortStore.apiValue).toBe('string'); + expect(typeof sortStore.set).toBe('function'); + }); + + it('accepts all SORT_OPTIONS as valid set() inputs', () => { + for (const option of SORT_OPTIONS) { + sortStore.set(option); + expect(sortStore.value).toBe(option); + expect(sortStore.apiValue).toBe(SORT_MAP[option]); + } + }); +});