Chore/architecture refactoring #42
@@ -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';
|
||||
|
||||
@@ -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(() => {
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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
|
||||
*/
|
||||
@@ -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];
|
||||
|
||||
function createSortStore(initial: SortOption = 'Popularity') {
|
||||
export function createSortStore(initial: SortOption = 'Popularity') {
|
||||
let current = $state<SortOption>(initial);
|
||||
|
||||
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