chore: move store creators to separate directories
This commit is contained in:
@@ -1,89 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
import {
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
} from 'vitest';
|
|
||||||
import {
|
|
||||||
type ControlModel,
|
|
||||||
createControlStore,
|
|
||||||
} from './createControlStore';
|
|
||||||
|
|
||||||
describe('createControlStore', () => {
|
|
||||||
let store: ReturnType<typeof createControlStore<number>>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const initialState: ControlModel<number> = {
|
|
||||||
value: 10,
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 5,
|
|
||||||
};
|
|
||||||
store = createControlStore(initialState);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('initializes with correct state', () => {
|
|
||||||
expect(get(store)).toEqual({
|
|
||||||
value: 10,
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 5,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('increases value by step', () => {
|
|
||||||
store.increase();
|
|
||||||
expect(get(store).value).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('decreases value by step', () => {
|
|
||||||
store.decrease();
|
|
||||||
expect(get(store).value).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps value at maximum', () => {
|
|
||||||
store.setValue(200);
|
|
||||||
expect(get(store).value).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps value at minimum', () => {
|
|
||||||
store.setValue(-10);
|
|
||||||
expect(get(store).value).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rounds to step precision', () => {
|
|
||||||
store.setValue(12.34);
|
|
||||||
// With step=5, 12.34 is clamped and rounded to nearest integer (0 decimal places)
|
|
||||||
expect(get(store).value).toBe(12);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles decimal steps correctly', () => {
|
|
||||||
const decimalStore = createControlStore({
|
|
||||||
value: 1.0,
|
|
||||||
min: 0,
|
|
||||||
max: 2,
|
|
||||||
step: 0.05,
|
|
||||||
});
|
|
||||||
decimalStore.increase();
|
|
||||||
expect(get(decimalStore).value).toBe(1.05);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isAtMax returns true when at maximum', () => {
|
|
||||||
store.setValue(100);
|
|
||||||
expect(store.isAtMax()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isAtMax returns false when not at maximum', () => {
|
|
||||||
expect(store.isAtMax()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isAtMin returns true when at minimum', () => {
|
|
||||||
store.setValue(0);
|
|
||||||
expect(store.isAtMin()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isAtMin returns false when not at minimum', () => {
|
|
||||||
expect(store.isAtMin()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import {
|
|
||||||
type Writable,
|
|
||||||
get,
|
|
||||||
writable,
|
|
||||||
} from 'svelte/store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Model for a control value with min/max bounds
|
|
||||||
*/
|
|
||||||
export type ControlModel<
|
|
||||||
TValue extends number = number,
|
|
||||||
> = {
|
|
||||||
value: TValue;
|
|
||||||
min: TValue;
|
|
||||||
max: TValue;
|
|
||||||
step?: TValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store model with methods for control manipulation
|
|
||||||
*/
|
|
||||||
export type ControlStoreModel<
|
|
||||||
TValue extends number,
|
|
||||||
> =
|
|
||||||
& Writable<ControlModel<TValue>>
|
|
||||||
& {
|
|
||||||
increase: () => void;
|
|
||||||
decrease: () => void;
|
|
||||||
/** Set a specific value */
|
|
||||||
setValue: (newValue: TValue) => void;
|
|
||||||
isAtMax: () => boolean;
|
|
||||||
isAtMin: () => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a writable store for numeric control values with bounds
|
|
||||||
*
|
|
||||||
* @template TValue - The value type (extends number)
|
|
||||||
* @param initialState - Initial state containing value, min, and max
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Get the number of decimal places in a number
|
|
||||||
*
|
|
||||||
* For example:
|
|
||||||
* - 1 -> 0
|
|
||||||
* - 0.1 -> 1
|
|
||||||
* - 0.01 -> 2
|
|
||||||
* - 0.05 -> 2
|
|
||||||
*
|
|
||||||
* @param step - The step number to analyze
|
|
||||||
* @returns The number of decimal places
|
|
||||||
*/
|
|
||||||
function getDecimalPlaces(step: number): number {
|
|
||||||
const str = step.toString();
|
|
||||||
const decimalPart = str.split('.')[1];
|
|
||||||
return decimalPart ? decimalPart.length : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Round a value to the precision of the given step
|
|
||||||
*
|
|
||||||
* This fixes floating-point precision errors that occur with decimal steps.
|
|
||||||
* For example, with step=0.05, adding it repeatedly can produce values like
|
|
||||||
* 1.3499999999999999 instead of 1.35.
|
|
||||||
*
|
|
||||||
* We use toFixed() to round to the appropriate decimal places instead of
|
|
||||||
* Math.round(value / step) * step, which doesn't always work correctly
|
|
||||||
* due to floating-point arithmetic errors.
|
|
||||||
*
|
|
||||||
* @param value - The value to round
|
|
||||||
* @param step - The step to round to (defaults to 1)
|
|
||||||
* @returns The rounded value
|
|
||||||
*/
|
|
||||||
function roundToStepPrecision(value: number, step: number = 1): number {
|
|
||||||
if (step <= 0) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
const decimals = getDecimalPlaces(step);
|
|
||||||
return parseFloat(value.toFixed(decimals));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createControlStore<
|
|
||||||
TValue extends number = number,
|
|
||||||
>(
|
|
||||||
initialState: ControlModel<TValue>,
|
|
||||||
): ControlStoreModel<TValue> {
|
|
||||||
const store = writable(initialState);
|
|
||||||
const { subscribe, set, update } = store;
|
|
||||||
|
|
||||||
const clamp = (value: number): TValue => {
|
|
||||||
return Math.max(initialState.min, Math.min(value, initialState.max)) as TValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscribe,
|
|
||||||
set,
|
|
||||||
update,
|
|
||||||
increase: () =>
|
|
||||||
update(m => {
|
|
||||||
const step = m.step ?? 1;
|
|
||||||
const newValue = clamp(m.value + step);
|
|
||||||
return { ...m, value: roundToStepPrecision(newValue, step) as TValue };
|
|
||||||
}),
|
|
||||||
decrease: () =>
|
|
||||||
update(m => {
|
|
||||||
const step = m.step ?? 1;
|
|
||||||
const newValue = clamp(m.value - step);
|
|
||||||
return { ...m, value: roundToStepPrecision(newValue, step) as TValue };
|
|
||||||
}),
|
|
||||||
setValue: (v: TValue) => {
|
|
||||||
const step = initialState.step ?? 1;
|
|
||||||
update(m => ({ ...m, value: roundToStepPrecision(clamp(v), step) as TValue }));
|
|
||||||
},
|
|
||||||
isAtMin: () => get(store).value === initialState.min,
|
|
||||||
isAtMax: () => get(store).value === initialState.max,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
import {
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
} from 'vitest';
|
|
||||||
import {
|
|
||||||
type FilterModel,
|
|
||||||
type Property,
|
|
||||||
createFilterStore,
|
|
||||||
} from './createFilterStore';
|
|
||||||
|
|
||||||
describe('createFilterStore', () => {
|
|
||||||
const mockProperties: Property[] = [
|
|
||||||
{ id: '1', name: 'Sans-serif', selected: false },
|
|
||||||
{ id: '2', name: 'Serif', selected: false },
|
|
||||||
{ id: '3', name: 'Display', selected: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
let store: ReturnType<typeof createFilterStore>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const initialState: FilterModel = {
|
|
||||||
searchQuery: '',
|
|
||||||
properties: mockProperties,
|
|
||||||
};
|
|
||||||
store = createFilterStore(initialState);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('initializes with correct state', () => {
|
|
||||||
const state = get(store);
|
|
||||||
expect(state).toEqual({
|
|
||||||
searchQuery: '',
|
|
||||||
properties: mockProperties,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets search query', () => {
|
|
||||||
store.setSearchQuery('serif');
|
|
||||||
const state = get(store);
|
|
||||||
expect(state.searchQuery).toBe('serif');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears search query', () => {
|
|
||||||
store.setSearchQuery('test');
|
|
||||||
store.clearSearchQuery();
|
|
||||||
const state = get(store);
|
|
||||||
expect(state.searchQuery).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('selects a property', () => {
|
|
||||||
store.selectProperty('1');
|
|
||||||
const state = get(store);
|
|
||||||
const property = state.properties.find(p => p.id === '1');
|
|
||||||
expect(property?.selected).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deselects a property', () => {
|
|
||||||
store.selectProperty('1');
|
|
||||||
store.deselectProperty('1');
|
|
||||||
const state = get(store);
|
|
||||||
const property = state.properties.find(p => p.id === '1');
|
|
||||||
expect(property?.selected).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toggles property from unselected to selected', () => {
|
|
||||||
store.toggleProperty('1');
|
|
||||||
const state = get(store);
|
|
||||||
const property = state.properties.find(p => p.id === '1');
|
|
||||||
expect(property?.selected).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toggles property from selected to unselected', () => {
|
|
||||||
store.selectProperty('1');
|
|
||||||
store.toggleProperty('1');
|
|
||||||
const state = get(store);
|
|
||||||
const property = state.properties.find(p => p.id === '1');
|
|
||||||
expect(property?.selected).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('selects all properties', () => {
|
|
||||||
store.selectAllProperties();
|
|
||||||
const state = get(store);
|
|
||||||
expect(state.properties.every(p => p.selected)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deselects all properties', () => {
|
|
||||||
store.selectAllProperties();
|
|
||||||
store.deselectAllProperties();
|
|
||||||
const state = get(store);
|
|
||||||
expect(state.properties.every(p => !p.selected)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gets all properties', () => {
|
|
||||||
const allProps = store.getAllProperties();
|
|
||||||
const props = get(allProps);
|
|
||||||
expect(props).toEqual(mockProperties);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gets selected properties', () => {
|
|
||||||
store.selectProperty('1');
|
|
||||||
store.selectProperty('3');
|
|
||||||
const selectedProps = store.getSelectedProperties();
|
|
||||||
const props = get(selectedProps);
|
|
||||||
expect(props).toHaveLength(2);
|
|
||||||
expect(props?.[0].id).toBe('1');
|
|
||||||
expect(props?.[1].id).toBe('3');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters properties by search query', () => {
|
|
||||||
store.setSearchQuery('serif');
|
|
||||||
const filteredProps = store.getFilteredProperties();
|
|
||||||
const props = get(filteredProps);
|
|
||||||
// 'serif' is a substring of 'Sans-serif' (case-sensitive match)
|
|
||||||
expect(props).toHaveLength(1);
|
|
||||||
expect(props?.[0].id).toBe('1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filter is case-sensitive', () => {
|
|
||||||
store.setSearchQuery('San');
|
|
||||||
const filteredProps = store.getFilteredProperties();
|
|
||||||
const props = get(filteredProps);
|
|
||||||
// 'San' matches 'Sans-serif' exactly (case-sensitive)
|
|
||||||
expect(props).toHaveLength(1);
|
|
||||||
expect(props?.[0].id).toBe('1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filter returns all properties when query is empty', () => {
|
|
||||||
store.setSearchQuery('');
|
|
||||||
const filteredProps = store.getFilteredProperties();
|
|
||||||
let props: Property[] | undefined = undefined;
|
|
||||||
filteredProps.subscribe(p => (props = p))();
|
|
||||||
expect(props).toHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import {
|
|
||||||
type Readable,
|
|
||||||
type Writable,
|
|
||||||
derived,
|
|
||||||
writable,
|
|
||||||
} from 'svelte/store';
|
|
||||||
|
|
||||||
export interface Property {
|
|
||||||
/**
|
|
||||||
* Property identifier
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/**
|
|
||||||
* Property name
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* Property selected state
|
|
||||||
*/
|
|
||||||
selected?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FilterModel {
|
|
||||||
/**
|
|
||||||
* Search query
|
|
||||||
*/
|
|
||||||
searchQuery?: string;
|
|
||||||
/**
|
|
||||||
* Properties
|
|
||||||
*/
|
|
||||||
properties: Property[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Model for reusable filter store with search support and property selection
|
|
||||||
*/
|
|
||||||
export interface FilterStore<T extends FilterModel> extends Writable<T> {
|
|
||||||
/**
|
|
||||||
* Get the store.
|
|
||||||
* @returns Readable store with filter data
|
|
||||||
*/
|
|
||||||
getStore: () => Readable<T>;
|
|
||||||
/**
|
|
||||||
* Get all properties.
|
|
||||||
* @returns Readable store with properties
|
|
||||||
*/
|
|
||||||
getAllProperties: () => Readable<Property[]>;
|
|
||||||
/**
|
|
||||||
* Get the selected properties.
|
|
||||||
* @returns Readable store with selected properties
|
|
||||||
*/
|
|
||||||
getSelectedProperties: () => Readable<Property[]>;
|
|
||||||
/**
|
|
||||||
* Get the filtered properties.
|
|
||||||
* @returns Readable store with filtered properties
|
|
||||||
*/
|
|
||||||
getFilteredProperties: () => Readable<Property[]>;
|
|
||||||
/**
|
|
||||||
* Update the search query filter.
|
|
||||||
*
|
|
||||||
* @param searchQuery - Search text (undefined to clear)
|
|
||||||
*/
|
|
||||||
setSearchQuery: (searchQuery: string | undefined) => void;
|
|
||||||
/**
|
|
||||||
* Clear the search query filter.
|
|
||||||
*/
|
|
||||||
clearSearchQuery: () => void;
|
|
||||||
/**
|
|
||||||
* Select a property.
|
|
||||||
*
|
|
||||||
* @param property - Property to select
|
|
||||||
*/
|
|
||||||
selectProperty: (propertyId: string) => void;
|
|
||||||
/**
|
|
||||||
* Deselect a property.
|
|
||||||
*
|
|
||||||
* @param property - Property to deselect
|
|
||||||
*/
|
|
||||||
deselectProperty: (propertyId: string) => void;
|
|
||||||
/**
|
|
||||||
* Toggle a property.
|
|
||||||
*
|
|
||||||
* @param propertyId - Property ID
|
|
||||||
*/
|
|
||||||
toggleProperty: (propertyId: string) => void;
|
|
||||||
/**
|
|
||||||
* Select all properties.
|
|
||||||
*/
|
|
||||||
selectAllProperties: () => void;
|
|
||||||
/**
|
|
||||||
* Deselect all properties.
|
|
||||||
*/
|
|
||||||
deselectAllProperties: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a filter store.
|
|
||||||
* @param initialState - Initial state of the filter store
|
|
||||||
* @returns FilterStore<T>
|
|
||||||
*/
|
|
||||||
export function createFilterStore<T extends FilterModel>(
|
|
||||||
initialState?: T,
|
|
||||||
): FilterStore<T> {
|
|
||||||
const { subscribe, set, update } = writable<T>(initialState);
|
|
||||||
|
|
||||||
return {
|
|
||||||
/*
|
|
||||||
* Expose subscribe, set, and update from Writable.
|
|
||||||
* This makes FilterStore compatible with Writable interface.
|
|
||||||
*/
|
|
||||||
subscribe,
|
|
||||||
set,
|
|
||||||
update,
|
|
||||||
/**
|
|
||||||
* Get the current state of the filter store.
|
|
||||||
*/
|
|
||||||
getStore: () => {
|
|
||||||
return {
|
|
||||||
subscribe,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Get the filtered properties.
|
|
||||||
*/
|
|
||||||
getAllProperties: () => {
|
|
||||||
return derived({ subscribe }, $store => {
|
|
||||||
return $store.properties;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Get the selected properties.
|
|
||||||
*/
|
|
||||||
getSelectedProperties: () => {
|
|
||||||
return derived({ subscribe }, $store => {
|
|
||||||
return $store.properties.filter(property => property.selected);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Get the filtered properties.
|
|
||||||
*/
|
|
||||||
getFilteredProperties: () => {
|
|
||||||
return derived({ subscribe }, $store => {
|
|
||||||
return $store.properties.filter(property =>
|
|
||||||
property.name.includes($store.searchQuery || '')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Update the search query filter.
|
|
||||||
*
|
|
||||||
* @param searchQuery - Search text (undefined to clear)
|
|
||||||
*/
|
|
||||||
setSearchQuery: (searchQuery: string | undefined) => {
|
|
||||||
update(state => ({
|
|
||||||
...state,
|
|
||||||
searchQuery: searchQuery || undefined,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Clear the search query filter.
|
|
||||||
*/
|
|
||||||
clearSearchQuery: () => {
|
|
||||||
update(state => ({
|
|
||||||
...state,
|
|
||||||
searchQuery: undefined,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Select a property.
|
|
||||||
*
|
|
||||||
* @param propertyId - Property ID
|
|
||||||
*/
|
|
||||||
selectProperty: (propertyId: string) => {
|
|
||||||
update(state => ({
|
|
||||||
...state,
|
|
||||||
properties: state.properties.map(c =>
|
|
||||||
c.id === propertyId ? { ...c, selected: true } : c
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Deselect a property.
|
|
||||||
*
|
|
||||||
* @param propertyId - Property ID
|
|
||||||
*/
|
|
||||||
deselectProperty: (propertyId: string) => {
|
|
||||||
update(state => ({
|
|
||||||
...state,
|
|
||||||
properties: state.properties.map(c =>
|
|
||||||
c.id === propertyId ? { ...c, selected: false } : c
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Toggle a property.
|
|
||||||
*
|
|
||||||
* @param propertyId - Property ID
|
|
||||||
*/
|
|
||||||
toggleProperty: (propertyId: string) => {
|
|
||||||
update(state => ({
|
|
||||||
...state,
|
|
||||||
properties: state.properties.map(c =>
|
|
||||||
c.id === propertyId ? { ...c, selected: !c.selected } : c
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Select all properties
|
|
||||||
*/
|
|
||||||
selectAllProperties: () => {
|
|
||||||
update(state => ({
|
|
||||||
...state,
|
|
||||||
properties: state.properties.map(c => ({ ...c, selected: true })),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Deselect all properties
|
|
||||||
*/
|
|
||||||
deselectAllProperties: () => {
|
|
||||||
update(state => ({
|
|
||||||
...state,
|
|
||||||
properties: state.properties.map(c => ({ ...c, selected: false })),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user