Chore/architecture refactoring #42
@@ -17,7 +17,7 @@ export {
|
|||||||
* Low-level property selection store
|
* Low-level property selection store
|
||||||
*/
|
*/
|
||||||
filtersStore,
|
filtersStore,
|
||||||
} from './store/filters.svelte';
|
} from './store/filters/filters.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main filter controller
|
* Main filter controller
|
||||||
@@ -67,4 +67,4 @@ export {
|
|||||||
* Reactive store for the current sort selection
|
* Reactive store for the current sort selection
|
||||||
*/
|
*/
|
||||||
sortStore,
|
sortStore,
|
||||||
} from './store/sortStore.svelte';
|
} from './store/sortStore/sortStore.svelte';
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import { fontStore } from '$entities/Font';
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { mapManagerToParams } from '../../lib/mapper/mapManagerToParams';
|
import { mapManagerToParams } from '../../lib/mapper/mapManagerToParams';
|
||||||
import { filterManager } from './filterManager/filterManager.svelte';
|
import { filterManager } from './filterManager/filterManager.svelte';
|
||||||
import { filtersStore } from './filters.svelte';
|
import { filtersStore } from './filters/filters.svelte';
|
||||||
import { sortStore } from './sortStore.svelte';
|
import { sortStore } from './sortStore/sortStore.svelte';
|
||||||
|
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ import {
|
|||||||
* Fetches and caches filter metadata using fetchProxyFilters()
|
* Fetches and caches filter metadata using fetchProxyFilters()
|
||||||
* Provides reactive access to filter data
|
* Provides reactive access to filter data
|
||||||
*/
|
*/
|
||||||
class FiltersStore {
|
export class FiltersStore {
|
||||||
/**
|
/**
|
||||||
* TanStack Query result state
|
* TanStack Query result state
|
||||||
*/
|
*/
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -17,7 +17,7 @@ export const SORT_MAP: Record<SortOption, 'name' | 'popularity' | 'lastModified'
|
|||||||
|
|
||||||
export type SortApiValue = (typeof SORT_MAP)[SortOption];
|
export type SortApiValue = (typeof SORT_MAP)[SortOption];
|
||||||
|
|
||||||
function createSortStore(initial: SortOption = 'Popularity') {
|
export function createSortStore(initial: SortOption = 'Popularity') {
|
||||||
let current = $state<SortOption>(initial);
|
let current = $state<SortOption>(initial);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user