Compare commits

...

7 Commits

Author SHA1 Message Date
Ilia Mashkov
51ea8a9902 test(smoothScroll): cast mock to the proper type 2026-02-18 20:40:00 +03:00
Ilia Mashkov
e81cadb32a feat(smoothScroll): cover smoothScroll util with unit tests 2026-02-18 20:20:24 +03:00
Ilia Mashkov
1c3908f89e test(createPersistentStore): cover createPersistentStore helper with unit tests 2026-02-18 20:19:47 +03:00
Ilia Mashkov
206e609a2d test(createEntityStore): cover createEntityStore helper with unit tests 2026-02-18 20:19:26 +03:00
Ilia Mashkov
ff71d1c8c9 test(splitArray): add unit tests for splitArray util 2026-02-18 20:18:18 +03:00
Ilia Mashkov
24ca2f6c41 test(throttle): add unit tests for throttle util 2026-02-18 20:17:33 +03:00
Ilia Mashkov
3abe5723c7 test(appliedFontStore): change mockFetch 2026-02-18 20:16:50 +03:00
6 changed files with 1924 additions and 8 deletions

View File

@@ -12,9 +12,12 @@ import { AppliedFontsManager } from './appliedFontsStore.svelte';
describe('AppliedFontsManager', () => { describe('AppliedFontsManager', () => {
let manager: AppliedFontsManager; let manager: AppliedFontsManager;
let mockFontFaceSet: any; let mockFontFaceSet: any;
let mockFetch: any;
let failUrls: Set<string>;
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
failUrls = new Set();
mockFontFaceSet = { mockFontFaceSet = {
add: vi.fn(), add: vi.fn(),
@@ -22,11 +25,13 @@ describe('AppliedFontsManager', () => {
}; };
// 1. Properly mock FontFace as a constructor function // 1. Properly mock FontFace as a constructor function
const MockFontFace = vi.fn(function(this: any, name: string, url: string) { // The actual implementation passes buffer (ArrayBuffer) as second arg, not URL string
const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
this.name = name; this.name = name;
this.url = url; this.bufferOrUrl = bufferOrUrl;
this.load = vi.fn().mockImplementation(() => { this.load = vi.fn().mockImplementation(() => {
if (url.includes('fail')) return Promise.reject(new Error('Load failed')); // For error tests, we track which URLs should fail via failUrls
// The fetch mock will have already rejected for those URLs
return Promise.resolve(this); return Promise.resolve(this);
}); });
}); });
@@ -44,18 +49,37 @@ describe('AppliedFontsManager', () => {
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any, randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
}); });
// 3. Mock fetch to return fake ArrayBuffer data
mockFetch = vi.fn((url: string) => {
if (failUrls.has(url)) {
return Promise.reject(new Error('Network error'));
}
return Promise.resolve({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
clone: () => ({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
}),
} as Response);
});
vi.stubGlobal('fetch', mockFetch);
manager = new AppliedFontsManager(); manager = new AppliedFontsManager();
}); });
afterEach(() => { afterEach(() => {
vi.clearAllTimers(); vi.clearAllTimers();
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllGlobals();
}); });
it('should batch multiple font requests into a single process', async () => { it('should batch multiple font requests into a single process', async () => {
const configs = [ const configs = [
{ id: 'lato-400', name: 'Lato', url: 'lato.ttf', weight: 400 }, { id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 },
{ id: 'lato-700', name: 'Lato', url: 'lato-bold.ttf', weight: 700 }, { id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 },
]; ];
manager.touch(configs); manager.touch(configs);
@@ -71,7 +95,10 @@ describe('AppliedFontsManager', () => {
// Suppress expected console error for clean test logs // Suppress expected console error for clean test logs
const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const config = { id: 'broken', name: 'Broken', url: 'fail.ttf', weight: 400 }; const failUrl = 'https://example.com/fail.ttf';
failUrls.add(failUrl);
const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 };
manager.touch([config]); manager.touch([config]);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
@@ -81,7 +108,7 @@ describe('AppliedFontsManager', () => {
}); });
it('should purge fonts after TTL expires', async () => { it('should purge fonts after TTL expires', async () => {
const config = { id: 'ephemeral', name: 'Temp', url: 'temp.ttf', weight: 400 }; const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
manager.touch([config]); manager.touch([config]);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);
@@ -96,7 +123,7 @@ describe('AppliedFontsManager', () => {
}); });
it('should NOT purge fonts that are still being "touched"', async () => { it('should NOT purge fonts that are still being "touched"', async () => {
const config = { id: 'active', name: 'Active', url: 'active.ttf', weight: 400 }; const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
manager.touch([config]); manager.touch([config]);
await vi.advanceTimersByTimeAsync(50); await vi.advanceTimersByTimeAsync(50);

View File

@@ -0,0 +1,420 @@
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import {
type Entity,
EntityStore,
createEntityStore,
} from './createEntityStore.svelte';
interface TestEntity {
id: string;
name: string;
value: number;
}
describe('createEntityStore', () => {
describe('Construction and Initialization', () => {
it('should create an empty store when no initial entities are provided', () => {
const store = createEntityStore<TestEntity>();
expect(store.all).toEqual([]);
});
it('should create a store with initial entities', () => {
const initialEntities: TestEntity[] = [
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
];
const store = createEntityStore(initialEntities);
expect(store.all).toHaveLength(2);
expect(store.all).toEqual(initialEntities);
});
it('should create EntityStore instance', () => {
const store = createEntityStore<TestEntity>();
expect(store).toBeInstanceOf(EntityStore);
});
});
describe('Selectors', () => {
let store: EntityStore<TestEntity>;
let entities: TestEntity[];
beforeEach(() => {
entities = [
{ id: '1', name: 'First', value: 10 },
{ id: '2', name: 'Second', value: 20 },
{ id: '3', name: 'Third', value: 30 },
];
store = createEntityStore(entities);
});
it('should return all entities as an array', () => {
const all = store.all;
expect(all).toEqual(entities);
expect(all).toHaveLength(3);
});
it('should get a single entity by ID', () => {
const entity = store.getById('2');
expect(entity).toEqual({ id: '2', name: 'Second', value: 20 });
});
it('should return undefined for non-existent ID', () => {
const entity = store.getById('999');
expect(entity).toBeUndefined();
});
it('should get multiple entities by IDs', () => {
const entities = store.getByIds(['1', '3']);
expect(entities).toEqual([
{ id: '1', name: 'First', value: 10 },
{ id: '3', name: 'Third', value: 30 },
]);
});
it('should filter out undefined results when getting by IDs', () => {
const entities = store.getByIds(['1', '999', '3']);
expect(entities).toEqual([
{ id: '1', name: 'First', value: 10 },
{ id: '3', name: 'Third', value: 30 },
]);
expect(entities).toHaveLength(2);
});
it('should return empty array when no IDs match', () => {
const entities = store.getByIds(['999', '888']);
expect(entities).toEqual([]);
});
it('should check if entity exists by ID', () => {
expect(store.has('1')).toBe(true);
expect(store.has('999')).toBe(false);
});
});
describe('CRUD Operations - Create', () => {
it('should add a single entity', () => {
const store = createEntityStore<TestEntity>();
store.addOne({ id: '1', name: 'First', value: 1 });
expect(store.all).toHaveLength(1);
expect(store.getById('1')).toEqual({ id: '1', name: 'First', value: 1 });
});
it('should add multiple entities at once', () => {
const store = createEntityStore<TestEntity>();
store.addMany([
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
{ id: '3', name: 'Third', value: 3 },
]);
expect(store.all).toHaveLength(3);
});
it('should replace entity when adding with existing ID', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
store.addOne({ id: '1', name: 'Updated', value: 2 });
expect(store.all).toHaveLength(1);
expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 2 });
});
});
describe('CRUD Operations - Update', () => {
it('should update an existing entity', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
store.updateOne('1', { name: 'Updated' });
expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 1 });
});
it('should update multiple properties at once', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
store.updateOne('1', { name: 'Updated', value: 2 });
expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 2 });
});
it('should do nothing when updating non-existent entity', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
store.updateOne('999', { name: 'Updated' });
expect(store.all).toHaveLength(1);
expect(store.getById('1')).toEqual({ id: '1', name: 'Original', value: 1 });
});
it('should preserve entity when no changes are provided', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
store.updateOne('1', {});
expect(store.getById('1')).toEqual({ id: '1', name: 'Original', value: 1 });
});
});
describe('CRUD Operations - Delete', () => {
it('should remove a single entity', () => {
const store = createEntityStore<TestEntity>([
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
]);
store.removeOne('1');
expect(store.all).toHaveLength(1);
expect(store.getById('1')).toBeUndefined();
expect(store.getById('2')).toEqual({ id: '2', name: 'Second', value: 2 });
});
it('should remove multiple entities', () => {
const store = createEntityStore<TestEntity>([
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
{ id: '3', name: 'Third', value: 3 },
]);
store.removeMany(['1', '3']);
expect(store.all).toHaveLength(1);
expect(store.getById('2')).toEqual({ id: '2', name: 'Second', value: 2 });
});
it('should do nothing when removing non-existent entity', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
store.removeOne('999');
expect(store.all).toHaveLength(1);
});
it('should handle empty array when removing many', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
store.removeMany([]);
expect(store.all).toHaveLength(1);
});
});
describe('Bulk Operations', () => {
it('should set all entities, replacing existing', () => {
const store = createEntityStore<TestEntity>([
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
]);
store.setAll([{ id: '3', name: 'Third', value: 3 }]);
expect(store.all).toHaveLength(1);
expect(store.getById('1')).toBeUndefined();
expect(store.getById('3')).toEqual({ id: '3', name: 'Third', value: 3 });
});
it('should clear all entities', () => {
const store = createEntityStore<TestEntity>([
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
]);
store.clear();
expect(store.all).toEqual([]);
expect(store.all).toHaveLength(0);
});
});
describe('Reactivity with SvelteMap', () => {
it('should return reactive arrays', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
// The all getter should return a fresh array (or reactive state)
const first = store.all;
const second = store.all;
// Both should have the same content
expect(first).toEqual(second);
});
it('should reflect changes in subsequent calls', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
expect(store.all).toHaveLength(1);
store.addOne({ id: '2', name: 'Second', value: 2 });
expect(store.all).toHaveLength(2);
});
});
describe('Edge Cases', () => {
it('should handle empty initial array', () => {
const store = createEntityStore<TestEntity>([]);
expect(store.all).toEqual([]);
});
it('should handle single entity', () => {
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
expect(store.all).toHaveLength(1);
expect(store.getById('1')).toEqual({ id: '1', name: 'First', value: 1 });
});
it('should handle entities with complex objects', () => {
interface ComplexEntity extends Entity {
id: string;
data: {
nested: {
value: string;
};
};
tags: string[];
}
const entity: ComplexEntity = {
id: '1',
data: { nested: { value: 'test' } },
tags: ['a', 'b', 'c'],
};
const store = createEntityStore<ComplexEntity>([entity]);
expect(store.getById('1')).toEqual(entity);
});
it('should handle numeric string IDs', () => {
const store = createEntityStore<TestEntity>([
{ id: '123', name: 'First', value: 1 },
{ id: '456', name: 'Second', value: 2 },
]);
expect(store.getById('123')).toEqual({ id: '123', name: 'First', value: 1 });
expect(store.getById('456')).toEqual({ id: '456', name: 'Second', value: 2 });
});
it('should handle UUID-like IDs', () => {
const uuid1 = '550e8400-e29b-41d4-a716-446655440000';
const uuid2 = '550e8400-e29b-41d4-a716-446655440001';
const store = createEntityStore<TestEntity>([
{ id: uuid1, name: 'First', value: 1 },
{ id: uuid2, name: 'Second', value: 2 },
]);
expect(store.getById(uuid1)).toEqual({ id: uuid1, name: 'First', value: 1 });
});
});
describe('Type Safety', () => {
it('should enforce Entity type with id property', () => {
// This test verifies type checking at compile time
const validEntity: TestEntity = { id: '1', name: 'Test', value: 1 };
const store = createEntityStore<TestEntity>([validEntity]);
expect(store.getById('1')).toEqual(validEntity);
});
it('should work with different entity types', () => {
interface User extends Entity {
id: string;
name: string;
email: string;
}
interface Product extends Entity {
id: string;
title: string;
price: number;
}
const userStore = createEntityStore<User>([
{ id: 'u1', name: 'John', email: 'john@example.com' },
]);
const productStore = createEntityStore<Product>([
{ id: 'p1', title: 'Widget', price: 9.99 },
]);
expect(userStore.getById('u1')?.email).toBe('john@example.com');
expect(productStore.getById('p1')?.price).toBe(9.99);
});
});
describe('Large Datasets', () => {
it('should handle large number of entities efficiently', () => {
const entities: TestEntity[] = Array.from({ length: 1000 }, (_, i) => ({
id: `id-${i}`,
name: `Entity ${i}`,
value: i,
}));
const store = createEntityStore(entities);
expect(store.all).toHaveLength(1000);
expect(store.getById('id-500')).toEqual({
id: 'id-500',
name: 'Entity 500',
value: 500,
});
});
it('should efficiently check existence in large dataset', () => {
const entities: TestEntity[] = Array.from({ length: 1000 }, (_, i) => ({
id: `id-${i}`,
name: `Entity ${i}`,
value: i,
}));
const store = createEntityStore(entities);
expect(store.has('id-999')).toBe(true);
expect(store.has('id-1000')).toBe(false);
});
});
describe('Method Chaining', () => {
it('should support chaining add operations', () => {
const store = createEntityStore<TestEntity>();
store.addOne({ id: '1', name: 'First', value: 1 });
store.addOne({ id: '2', name: 'Second', value: 2 });
store.addOne({ id: '3', name: 'Third', value: 3 });
expect(store.all).toHaveLength(3);
});
it('should support chaining update operations', () => {
const store = createEntityStore<TestEntity>([
{ id: '1', name: 'First', value: 1 },
{ id: '2', name: 'Second', value: 2 },
]);
store.updateOne('1', { value: 10 });
store.updateOne('2', { value: 20 });
expect(store.getById('1')?.value).toBe(10);
expect(store.getById('2')?.value).toBe(20);
});
});
});

View File

@@ -0,0 +1,377 @@
/** @vitest-environment jsdom */
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { createPersistentStore } from './createPersistentStore.svelte';
describe('createPersistentStore', () => {
let mockLocalStorage: Storage;
const testKey = 'test-store-key';
beforeEach(() => {
// Mock localStorage
const storeMap = new Map<string, string>();
mockLocalStorage = {
get length() {
return storeMap.size;
},
clear() {
storeMap.clear();
},
getItem(key: string) {
return storeMap.get(key) ?? null;
},
setItem(key: string, value: string) {
storeMap.set(key, value);
},
removeItem(key: string) {
storeMap.delete(key);
},
key(index: number) {
return Array.from(storeMap.keys())[index] ?? null;
},
};
vi.stubGlobal('localStorage', mockLocalStorage);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('Initialization', () => {
it('should create store with default value when localStorage is empty', () => {
const store = createPersistentStore(testKey, 'default');
expect(store.value).toBe('default');
});
it('should create store with value from localStorage', () => {
mockLocalStorage.setItem(testKey, JSON.stringify('stored value'));
const store = createPersistentStore(testKey, 'default');
expect(store.value).toBe('stored value');
});
it('should parse JSON from localStorage', () => {
const storedValue = { name: 'Test', count: 42 };
mockLocalStorage.setItem(testKey, JSON.stringify(storedValue));
const store = createPersistentStore(testKey, { name: 'Default', count: 0 });
expect(store.value).toEqual(storedValue);
});
it('should use default value when localStorage has invalid JSON', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
mockLocalStorage.setItem(testKey, 'invalid json{');
const store = createPersistentStore(testKey, 'default');
expect(store.value).toBe('default');
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('Reading Values', () => {
it('should return current value via getter', () => {
const store = createPersistentStore(testKey, 'default');
expect(store.value).toBe('default');
});
it('should return updated value after setter', () => {
const store = createPersistentStore(testKey, 'default');
store.value = 'updated';
expect(store.value).toBe('updated');
});
it('should preserve type information', () => {
interface TestObject {
name: string;
count: number;
}
const defaultValue: TestObject = { name: 'Test', count: 0 };
const store = createPersistentStore<TestObject>(testKey, defaultValue);
expect(store.value.name).toBe('Test');
expect(store.value.count).toBe(0);
});
});
describe('Writing Values', () => {
it('should update value when set via setter', () => {
const store = createPersistentStore(testKey, 'default');
store.value = 'new value';
expect(store.value).toBe('new value');
});
it('should serialize objects to JSON', () => {
const store = createPersistentStore(testKey, { name: 'Default', count: 0 });
store.value = { name: 'Updated', count: 42 };
// The value is updated in the store
expect(store.value).toEqual({ name: 'Updated', count: 42 });
});
it('should handle arrays', () => {
const store = createPersistentStore<number[]>(testKey, []);
store.value = [1, 2, 3];
expect(store.value).toEqual([1, 2, 3]);
});
it('should handle booleans', () => {
const store = createPersistentStore<boolean>(testKey, false);
store.value = true;
expect(store.value).toBe(true);
});
it('should handle null values', () => {
const store = createPersistentStore<string | null>(testKey, null);
store.value = 'not null';
expect(store.value).toBe('not null');
});
});
describe('Clear Function', () => {
it('should reset value to default when clear is called', () => {
const store = createPersistentStore(testKey, 'default');
store.value = 'modified';
store.clear();
expect(store.value).toBe('default');
});
it('should work with object defaults', () => {
const defaultValue = { name: 'Default', count: 0 };
const store = createPersistentStore(testKey, defaultValue);
store.value = { name: 'Modified', count: 42 };
store.clear();
expect(store.value).toEqual(defaultValue);
});
it('should work with array defaults', () => {
const defaultValue = [1, 2, 3];
const store = createPersistentStore<number[]>(testKey, defaultValue);
store.value = [4, 5, 6];
store.clear();
expect(store.value).toEqual(defaultValue);
});
});
describe('Type Support', () => {
it('should work with string type', () => {
const store = createPersistentStore<string>(testKey, 'default');
store.value = 'test string';
expect(store.value).toBe('test string');
});
it('should work with number type', () => {
const store = createPersistentStore<number>(testKey, 0);
store.value = 42;
expect(store.value).toBe(42);
});
it('should work with boolean type', () => {
const store = createPersistentStore<boolean>(testKey, false);
store.value = true;
expect(store.value).toBe(true);
});
it('should work with object type', () => {
interface TestObject {
name: string;
value: number;
}
const defaultValue: TestObject = { name: 'Test', value: 0 };
const store = createPersistentStore<TestObject>(testKey, defaultValue);
store.value = { name: 'Updated', value: 42 };
expect(store.value.name).toBe('Updated');
expect(store.value.value).toBe(42);
});
it('should work with array type', () => {
const store = createPersistentStore<string[]>(testKey, []);
store.value = ['a', 'b', 'c'];
expect(store.value).toEqual(['a', 'b', 'c']);
});
it('should work with null type', () => {
const store = createPersistentStore<string | null>(testKey, null);
expect(store.value).toBeNull();
store.value = 'not null';
expect(store.value).toBe('not null');
});
});
describe('Edge Cases', () => {
it('should handle empty string', () => {
const store = createPersistentStore(testKey, 'default');
store.value = '';
expect(store.value).toBe('');
});
it('should handle zero number', () => {
const store = createPersistentStore<number>(testKey, 100);
store.value = 0;
expect(store.value).toBe(0);
});
it('should handle false boolean', () => {
const store = createPersistentStore<boolean>(testKey, true);
store.value = false;
expect(store.value).toBe(false);
});
it('should handle empty array', () => {
const store = createPersistentStore<number[]>(testKey, [1, 2, 3]);
store.value = [];
expect(store.value).toEqual([]);
});
it('should handle empty object', () => {
const store = createPersistentStore<Record<string, unknown>>(testKey, { a: 1 });
store.value = {};
expect(store.value).toEqual({});
});
it('should handle special characters in string', () => {
const store = createPersistentStore(testKey, '');
const specialString = 'Hello "world"\nNew line\tTab';
store.value = specialString;
expect(store.value).toBe(specialString);
});
it('should handle unicode characters', () => {
const store = createPersistentStore(testKey, '');
store.value = 'Hello 世界 🌍';
expect(store.value).toBe('Hello 世界 🌍');
});
});
describe('Multiple Instances', () => {
it('should handle multiple stores with different keys', () => {
const store1 = createPersistentStore('key1', 'value1');
const store2 = createPersistentStore('key2', 'value2');
store1.value = 'updated1';
store2.value = 'updated2';
expect(store1.value).toBe('updated1');
expect(store2.value).toBe('updated2');
});
it('should keep stores independent', () => {
const store1 = createPersistentStore('key1', 'default1');
const store2 = createPersistentStore('key2', 'default2');
store1.clear();
expect(store1.value).toBe('default1');
expect(store2.value).toBe('default2');
});
});
describe('Complex Scenarios', () => {
it('should handle nested objects', () => {
interface NestedObject {
user: {
name: string;
settings: {
theme: string;
notifications: boolean;
};
};
}
const defaultValue: NestedObject = {
user: {
name: 'Test',
settings: { theme: 'light', notifications: true },
},
};
const store = createPersistentStore<NestedObject>(testKey, defaultValue);
store.value = {
user: {
name: 'Updated',
settings: { theme: 'dark', notifications: false },
},
};
expect(store.value).toEqual({
user: {
name: 'Updated',
settings: { theme: 'dark', notifications: false },
},
});
});
it('should handle arrays of objects', () => {
interface Item {
id: number;
name: string;
}
const store = createPersistentStore<Item[]>(testKey, []);
store.value = [
{ id: 1, name: 'First' },
{ id: 2, name: 'Second' },
{ id: 3, name: 'Third' },
];
expect(store.value).toHaveLength(3);
expect(store.value[0].name).toBe('First');
});
});
});

View File

@@ -0,0 +1,368 @@
/** @vitest-environment jsdom */
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { smoothScroll } from './smoothScroll';
describe('smoothScroll', () => {
let mockAnchor: HTMLAnchorElement;
let mockTarget: HTMLElement;
let mockScrollIntoView: ReturnType<typeof vi.fn>;
let mockPushState: ReturnType<typeof vi.fn>;
beforeEach(() => {
// Mock scrollIntoView
mockScrollIntoView = vi.fn();
HTMLElement.prototype.scrollIntoView = mockScrollIntoView as (arg?: boolean | ScrollIntoViewOptions) => void;
// Mock history.pushState
mockPushState = vi.fn();
vi.stubGlobal('history', {
pushState: mockPushState,
});
// Create mock elements
mockAnchor = document.createElement('a');
mockAnchor.setAttribute('href', '#section-1');
mockTarget = document.createElement('div');
mockTarget.id = 'section-1';
document.body.appendChild(mockTarget);
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
document.body.innerHTML = '';
});
describe('Basic Functionality', () => {
it('should be a function that returns an object with destroy method', () => {
const action = smoothScroll(mockAnchor);
expect(typeof action).toBe('object');
expect(typeof action.destroy).toBe('function');
});
it('should add click event listener to the anchor element', () => {
const addEventListenerSpy = vi.spyOn(mockAnchor, 'addEventListener');
smoothScroll(mockAnchor);
expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
addEventListenerSpy.mockRestore();
});
it('should remove click event listener when destroy is called', () => {
const action = smoothScroll(mockAnchor);
const removeEventListenerSpy = vi.spyOn(mockAnchor, 'removeEventListener');
action.destroy();
expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
removeEventListenerSpy.mockRestore();
});
});
describe('Click Handling', () => {
it('should prevent default behavior on click', () => {
const mockEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
const preventDefaultSpy = vi.spyOn(mockEvent, 'preventDefault');
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
preventDefaultSpy.mockRestore();
});
it('should scroll to target element when clicked', () => {
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).toHaveBeenCalledWith({
behavior: 'smooth',
block: 'start',
});
});
it('should update URL hash without jumping when clicked', () => {
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1');
});
});
describe('Edge Cases', () => {
it('should do nothing when href attribute is missing', () => {
mockAnchor.removeAttribute('href');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).not.toHaveBeenCalled();
expect(mockPushState).not.toHaveBeenCalled();
});
it('should do nothing when href is just "#"', () => {
mockAnchor.setAttribute('href', '#');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).not.toHaveBeenCalled();
expect(mockPushState).not.toHaveBeenCalled();
});
it('should do nothing when target element does not exist', () => {
mockAnchor.setAttribute('href', '#non-existent');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).not.toHaveBeenCalled();
expect(mockPushState).not.toHaveBeenCalled();
});
it('should handle empty href attribute', () => {
mockAnchor.setAttribute('href', '');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).not.toHaveBeenCalled();
});
});
describe('Multiple Anchors', () => {
it('should work correctly with multiple anchor elements', () => {
const anchor1 = document.createElement('a');
anchor1.setAttribute('href', '#section-1');
const target1 = document.createElement('div');
target1.id = 'section-1';
document.body.appendChild(target1);
const anchor2 = document.createElement('a');
anchor2.setAttribute('href', '#section-2');
const target2 = document.createElement('div');
target2.id = 'section-2';
document.body.appendChild(target2);
const action1 = smoothScroll(anchor1);
const action2 = smoothScroll(anchor2);
const event1 = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor1.dispatchEvent(event1);
expect(mockScrollIntoView).toHaveBeenCalledTimes(1);
const event2 = new MouseEvent('click', { bubbles: true, cancelable: true });
anchor2.dispatchEvent(event2);
expect(mockScrollIntoView).toHaveBeenCalledTimes(2);
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-2');
// Cleanup
action1.destroy();
action2.destroy();
});
});
describe('Cleanup', () => {
it('should not trigger clicks after destroy is called', () => {
const action = smoothScroll(mockAnchor);
action.destroy();
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).not.toHaveBeenCalled();
expect(mockPushState).not.toHaveBeenCalled();
});
it('should allow multiple destroy calls without errors', () => {
const action = smoothScroll(mockAnchor);
expect(() => {
action.destroy();
action.destroy();
action.destroy();
}).not.toThrow();
});
});
describe('Scroll Options', () => {
it('should always use smooth behavior', () => {
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({
behavior: 'smooth',
}),
);
});
it('should always use block: start', () => {
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).toHaveBeenCalledWith(
expect.objectContaining({
block: 'start',
}),
);
});
});
describe('Different Hash Formats', () => {
it('should handle simple hash like "#section"', () => {
const target = document.createElement('div');
target.id = 'section';
document.body.appendChild(target);
mockAnchor.setAttribute('href', '#section');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).toHaveBeenCalled();
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section');
});
it('should handle hash with multiple words like "#my-section"', () => {
const target = document.createElement('div');
target.id = 'my-section';
document.body.appendChild(target);
mockAnchor.setAttribute('href', '#my-section');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).toHaveBeenCalled();
expect(mockPushState).toHaveBeenCalledWith(null, '', '#my-section');
});
it('should handle hash with numbers like "#section-1-2"', () => {
const target = document.createElement('div');
target.id = 'section-1-2';
document.body.appendChild(target);
mockAnchor.setAttribute('href', '#section-1-2');
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
smoothScroll(mockAnchor);
mockAnchor.dispatchEvent(mockEvent);
expect(mockScrollIntoView).toHaveBeenCalled();
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1-2');
});
});
describe('Special Cases', () => {
it('should gracefully handle missing history.pushState', () => {
// Create a fresh test environment
const testAnchor = document.createElement('a');
testAnchor.href = '#test';
const testTarget = document.createElement('div');
testTarget.id = 'test';
document.body.appendChild(testTarget);
// Don't stub history - the action should still work without it
const action = smoothScroll(testAnchor);
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
// Should not throw even if history.pushState might not exist
expect(() => testAnchor.dispatchEvent(mockEvent)).not.toThrow();
action.destroy();
testTarget.remove();
});
});
describe('Return Value', () => {
it('should return an action object compatible with Svelte use directive', () => {
const action = smoothScroll(mockAnchor);
expect(action).toHaveProperty('destroy');
expect(typeof action.destroy).toBe('function');
});
it('should allow chaining destroy calls', () => {
const action = smoothScroll(mockAnchor);
const result = action.destroy();
expect(result).toBeUndefined();
});
});
describe('Real-World Scenarios', () => {
it('should handle table of contents navigation', () => {
const sections = ['intro', 'features', 'pricing', 'contact'];
sections.forEach(id => {
const section = document.createElement('section');
section.id = id;
document.body.appendChild(section);
const link = document.createElement('a');
link.href = `#${id}`;
document.body.appendChild(link);
const action = smoothScroll(link);
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
link.dispatchEvent(event);
expect(mockScrollIntoView).toHaveBeenCalled();
action.destroy();
});
expect(mockScrollIntoView).toHaveBeenCalledTimes(sections.length);
});
it('should work with back-to-top button', () => {
const topAnchor = document.createElement('a');
topAnchor.href = '#top';
document.body.appendChild(topAnchor);
const topElement = document.createElement('div');
topElement.id = 'top';
document.body.prepend(topElement);
const action = smoothScroll(topAnchor);
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
topAnchor.dispatchEvent(event);
expect(mockScrollIntoView).toHaveBeenCalled();
action.destroy();
});
});
});

View File

@@ -0,0 +1,405 @@
import {
describe,
expect,
it,
} from 'vitest';
import { splitArray } from './splitArray';
describe('splitArray', () => {
describe('Basic Functionality', () => {
it('should split an array into two arrays based on callback', () => {
const input = [1, 2, 3, 4, 5];
const [pass, fail] = splitArray(input, n => n > 2);
expect(pass).toEqual([3, 4, 5]);
expect(fail).toEqual([1, 2]);
});
it('should return two arrays', () => {
const result = splitArray([1, 2, 3], () => true);
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
expect(Array.isArray(result[0])).toBe(true);
expect(Array.isArray(result[1])).toBe(true);
});
it('should preserve original array', () => {
const input = [1, 2, 3, 4, 5];
const original = [...input];
splitArray(input, n => n % 2 === 0);
expect(input).toEqual(original);
});
});
describe('Empty Array', () => {
it('should return two empty arrays for empty input', () => {
const [pass, fail] = splitArray([], () => true);
expect(pass).toEqual([]);
expect(fail).toEqual([]);
});
it('should handle empty array with falsy callback', () => {
const [pass, fail] = splitArray([], () => false);
expect(pass).toEqual([]);
expect(fail).toEqual([]);
});
});
describe('All Pass', () => {
it('should put all elements in pass array when callback returns true for all', () => {
const input = [1, 2, 3, 4, 5];
const [pass, fail] = splitArray(input, () => true);
expect(pass).toEqual([1, 2, 3, 4, 5]);
expect(fail).toEqual([]);
});
it('should put all elements in pass array using always-true condition', () => {
const input = ['a', 'b', 'c'];
const [pass, fail] = splitArray(input, s => s.length > 0);
expect(pass).toEqual(['a', 'b', 'c']);
expect(fail).toEqual([]);
});
});
describe('All Fail', () => {
it('should put all elements in fail array when callback returns false for all', () => {
const input = [1, 2, 3, 4, 5];
const [pass, fail] = splitArray(input, () => false);
expect(pass).toEqual([]);
expect(fail).toEqual([1, 2, 3, 4, 5]);
});
it('should put all elements in fail array using always-false condition', () => {
const input = ['a', 'b', 'c'];
const [pass, fail] = splitArray(input, s => s.length > 10);
expect(pass).toEqual([]);
expect(fail).toEqual(['a', 'b', 'c']);
});
});
describe('Mixed Results', () => {
it('should split even and odd numbers', () => {
const input = [1, 2, 3, 4, 5, 6];
const [even, odd] = splitArray(input, n => n % 2 === 0);
expect(even).toEqual([2, 4, 6]);
expect(odd).toEqual([1, 3, 5]);
});
it('should split positive and negative numbers', () => {
const input = [-3, -2, -1, 0, 1, 2, 3];
const [positive, negative] = splitArray(input, n => n >= 0);
expect(positive).toEqual([0, 1, 2, 3]);
expect(negative).toEqual([-3, -2, -1]);
});
it('should split strings by length', () => {
const input = ['a', 'ab', 'abc', 'abcd'];
const [long, short] = splitArray(input, s => s.length >= 3);
expect(long).toEqual(['abc', 'abcd']);
expect(short).toEqual(['a', 'ab']);
});
it('should split objects by property', () => {
interface Item {
id: number;
active: boolean;
}
const input: Item[] = [
{ id: 1, active: true },
{ id: 2, active: false },
{ id: 3, active: true },
{ id: 4, active: false },
];
const [active, inactive] = splitArray(input, item => item.active);
expect(active).toEqual([
{ id: 1, active: true },
{ id: 3, active: true },
]);
expect(inactive).toEqual([
{ id: 2, active: false },
{ id: 4, active: false },
]);
});
});
describe('Type Safety', () => {
it('should work with number arrays', () => {
const [pass, fail] = splitArray([1, 2, 3], n => n > 1);
expect(pass).toEqual([2, 3]);
expect(fail).toEqual([1]);
// Type check - should be numbers
const sum = pass[0] + pass[1];
expect(sum).toBe(5);
});
it('should work with string arrays', () => {
const [pass, fail] = splitArray(['a', 'bb', 'ccc'], s => s.length > 1);
expect(pass).toEqual(['bb', 'ccc']);
expect(fail).toEqual(['a']);
// Type check - should be strings
const concatenated = pass.join('');
expect(concatenated).toBe('bbccc');
});
it('should work with boolean arrays', () => {
const [pass, fail] = splitArray([true, false, true], b => b);
expect(pass).toEqual([true, true]);
expect(fail).toEqual([false]);
});
it('should work with generic objects', () => {
interface Person {
name: string;
age: number;
}
const people: Person[] = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 },
{ name: 'Charlie', age: 20 },
];
const [adults, minors] = splitArray(people, p => p.age >= 21);
expect(adults).toEqual([
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 },
]);
expect(minors).toEqual([{ name: 'Charlie', age: 20 }]);
});
it('should work with null and undefined', () => {
const input = [null, undefined, 1, 0, ''];
const [truthy, falsy] = splitArray(input, item => !!item);
expect(truthy).toEqual([1]);
expect(falsy).toEqual([null, undefined, 0, '']);
});
});
describe('Callback Functions', () => {
it('should support arrow function syntax', () => {
const [pass, fail] = splitArray([1, 2, 3, 4], x => x % 2 === 0);
expect(pass).toEqual([2, 4]);
expect(fail).toEqual([1, 3]);
});
it('should support regular function syntax', () => {
const [pass, fail] = splitArray([1, 2, 3, 4], function(x) {
return x % 2 === 0;
});
expect(pass).toEqual([2, 4]);
expect(fail).toEqual([1, 3]);
});
it('should support inline conditions', () => {
const input = [1, 2, 3, 4, 5];
const [greaterThan3, others] = splitArray(input, x => x > 3);
expect(greaterThan3).toEqual([4, 5]);
expect(others).toEqual([1, 2, 3]);
});
});
describe('Order Preservation', () => {
it('should maintain order within each resulting array', () => {
const input = [5, 1, 4, 2, 3];
const [greaterThan2, lessOrEqual] = splitArray(input, n => n > 2);
expect(greaterThan2).toEqual([5, 4, 3]);
expect(lessOrEqual).toEqual([1, 2]);
});
it('should preserve relative order for complex objects', () => {
interface Item {
id: number;
value: string;
}
const input: Item[] = [
{ id: 1, value: 'a' },
{ id: 2, value: 'b' },
{ id: 3, value: 'c' },
{ id: 4, value: 'd' },
];
const [evenIds, oddIds] = splitArray(input, item => item.id % 2 === 0);
expect(evenIds).toEqual([
{ id: 2, value: 'b' },
{ id: 4, value: 'd' },
]);
expect(oddIds).toEqual([
{ id: 1, value: 'a' },
{ id: 3, value: 'c' },
]);
});
});
describe('Edge Cases', () => {
it('should handle single element array (truthy)', () => {
const [pass, fail] = splitArray([1], () => true);
expect(pass).toEqual([1]);
expect(fail).toEqual([]);
});
it('should handle single element array (falsy)', () => {
const [pass, fail] = splitArray([1], () => false);
expect(pass).toEqual([]);
expect(fail).toEqual([1]);
});
it('should handle two element array', () => {
const [pass, fail] = splitArray([1, 2], n => n === 1);
expect(pass).toEqual([1]);
expect(fail).toEqual([2]);
});
it('should handle array with duplicate values', () => {
const [pass, fail] = splitArray([1, 1, 2, 2, 1, 1], n => n === 1);
expect(pass).toEqual([1, 1, 1, 1]);
expect(fail).toEqual([2, 2]);
});
it('should handle zero values', () => {
const [truthy, falsy] = splitArray([0, 1, 0, 2], Boolean);
expect(truthy).toEqual([1, 2]);
expect(falsy).toEqual([0, 0]);
});
it('should handle NaN values', () => {
const input = [1, NaN, 2, NaN, 3];
const [numbers, nans] = splitArray(input, n => !Number.isNaN(n));
expect(numbers).toEqual([1, 2, 3]);
expect(nans).toEqual([NaN, NaN]);
});
});
describe('Large Arrays', () => {
it('should handle large arrays efficiently', () => {
const largeArray = Array.from({ length: 10000 }, (_, i) => i);
const [even, odd] = splitArray(largeArray, n => n % 2 === 0);
expect(even).toHaveLength(5000);
expect(odd).toHaveLength(5000);
expect(even[0]).toBe(0);
expect(even[9999]).toBeUndefined();
expect(even[4999]).toBe(9998);
});
it('should maintain correct results for all elements in large array', () => {
const input = Array.from({ length: 1000 }, (_, i) => i);
const [multiplesOf3, others] = splitArray(input, n => n % 3 === 0);
// Verify counts
expect(multiplesOf3).toHaveLength(334); // 0, 3, 6, ..., 999
expect(others).toHaveLength(666);
// Verify all multiples of 3 are in correct array
multiplesOf3.forEach(n => {
expect(n % 3).toBe(0);
});
// Verify no multiples of 3 are in others
others.forEach(n => {
expect(n % 3).not.toBe(0);
});
});
});
describe('Real-World Use Cases', () => {
it('should separate valid from invalid emails', () => {
const emails = [
'valid@example.com',
'invalid',
'another@test.org',
'not-an-email',
'user@domain.co.uk',
];
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const [valid, invalid] = splitArray(emails, email => emailRegex.test(email));
expect(valid).toEqual([
'valid@example.com',
'another@test.org',
'user@domain.co.uk',
]);
expect(invalid).toEqual(['invalid', 'not-an-email']);
});
it('should separate completed from pending tasks', () => {
interface Task {
id: number;
title: string;
completed: boolean;
}
const tasks: Task[] = [
{ id: 1, title: 'Task 1', completed: true },
{ id: 2, title: 'Task 2', completed: false },
{ id: 3, title: 'Task 3', completed: true },
{ id: 4, title: 'Task 4', completed: false },
];
const [completed, pending] = splitArray(tasks, task => task.completed);
expect(completed).toHaveLength(2);
expect(pending).toHaveLength(2);
expect(completed.every(t => t.completed)).toBe(true);
expect(pending.every(t => !t.completed)).toBe(true);
});
it('should separate adults from minors by age', () => {
interface Person {
name: string;
age: number;
}
const people: Person[] = [
{ name: 'Alice', age: 17 },
{ name: 'Bob', age: 25 },
{ name: 'Charlie', age: 16 },
{ name: 'Diana', age: 30 },
{ name: 'Eve', age: 18 },
];
const [adults, minors] = splitArray(people, person => person.age >= 18);
expect(adults).toEqual([
{ name: 'Bob', age: 25 },
{ name: 'Diana', age: 30 },
{ name: 'Eve', age: 18 },
]);
expect(minors).toEqual([
{ name: 'Alice', age: 17 },
{ name: 'Charlie', age: 16 },
]);
});
it('should separate truthy from falsy values', () => {
const mixed = [0, 1, false, true, '', 'hello', null, undefined, [], [0]];
const [truthy, falsy] = splitArray(mixed, Boolean);
expect(truthy).toEqual([1, true, 'hello', [], [0]]);
expect(falsy).toEqual([0, false, '', null, undefined]);
});
});
});

View File

@@ -0,0 +1,319 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { throttle } from './throttle';
describe('throttle', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('Basic Functionality', () => {
it('should execute function immediately on first call', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 300);
throttled('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
});
it('should throttle subsequent calls within wait period', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 300);
throttled('first');
expect(mockFn).toHaveBeenCalledTimes(1);
// Call again within wait period - should not execute
throttled('second');
expect(mockFn).toHaveBeenCalledTimes(1);
// Advance time past wait period
vi.advanceTimersByTime(300);
// Now trailing call executes
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenLastCalledWith('second');
});
it('should allow execution after wait period expires', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled('first');
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(100);
throttled('second');
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
describe('Trailing Edge Execution', () => {
it('should execute throttled call after wait period', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 300);
throttled('first');
expect(mockFn).toHaveBeenCalledTimes(1);
throttled('second');
throttled('third');
// Still 1 because these are throttled
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(300);
// Trailing call executes
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenLastCalledWith('third');
});
it('should cancel previous trailing call on new invocation', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled('first');
vi.advanceTimersByTime(50);
throttled('second');
vi.advanceTimersByTime(30);
throttled('third');
// At this point only first call executed
expect(mockFn).toHaveBeenCalledTimes(1);
// Advance to trigger trailing call
vi.advanceTimersByTime(70);
// First call + trailing (third)
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenLastCalledWith('third');
});
});
describe('Arguments and Context', () => {
it('should pass the correct arguments from the last throttled call', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled('arg1', 'arg2');
vi.advanceTimersByTime(50);
throttled('arg3', 'arg4');
vi.advanceTimersByTime(100);
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenLastCalledWith('arg3', 'arg4');
});
it('should handle no arguments', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled();
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should handle single argument', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled('single');
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('single');
});
it('should handle multiple arguments', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled(1, 2, 3, 'four', { five: 5 });
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(1, 2, 3, 'four', { five: 5 });
});
});
describe('Timing', () => {
it('should handle very short wait times (1ms)', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 1);
throttled('first');
vi.advanceTimersByTime(1);
throttled('second');
expect(mockFn).toHaveBeenCalledTimes(2);
});
it('should handle longer wait times (1000ms)', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 1000);
throttled('first');
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(500);
throttled('second');
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(500);
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
describe('Rapid Calls', () => {
it('should handle rapid successive calls correctly', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled('call1');
vi.advanceTimersByTime(10);
throttled('call2');
vi.advanceTimersByTime(10);
throttled('call3');
vi.advanceTimersByTime(10);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('call1');
vi.advanceTimersByTime(100);
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenLastCalledWith('call3');
});
it('should execute function at most once per wait period plus trailing', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
// Make many rapid calls
for (let i = 0; i < 10; i++) {
vi.advanceTimersByTime(5);
throttled(`call${i}`);
}
// Should execute immediately
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(100);
// Plus trailing call
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
describe('Edge Cases', () => {
it('should handle zero wait time', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 0);
throttled('first');
// With zero wait time, function may execute synchronously
// but the internal timing may still prevent immediate re-execution
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should handle being called at exactly wait boundary', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 100);
throttled('first');
vi.advanceTimersByTime(100);
throttled('second');
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
describe('Return Value', () => {
it('should not return anything (void)', () => {
const mockFn = vi.fn().mockReturnValue('result');
const throttled = throttle(mockFn, 100);
const result = throttled('arg');
expect(result).toBeUndefined();
});
});
describe('Real-World Scenarios', () => {
it('should throttle scroll-like events', () => {
const mockFn = vi.fn();
const throttledScroll = throttle(mockFn, 100);
throttledScroll();
vi.advanceTimersByTime(10);
throttledScroll();
vi.advanceTimersByTime(10);
throttledScroll();
vi.advanceTimersByTime(10);
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(100);
expect(mockFn).toHaveBeenCalledTimes(2);
});
it('should throttle resize-like events', () => {
const mockFn = vi.fn();
const throttledResize = throttle(mockFn, 200);
throttledResize();
for (let i = 1; i <= 10; i++) {
vi.advanceTimersByTime(10);
throttledResize();
}
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(200);
expect(mockFn).toHaveBeenCalledTimes(2);
});
});
describe('Comparison Characteristics', () => {
it('should execute immediately on first call', () => {
const mockFn = vi.fn();
const throttled = throttle(mockFn, 300);
throttled('first');
// Throttle executes immediately (unlike debounce)
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should allow execution during continuous calls at intervals', () => {
const mockFn = vi.fn();
const waitTime = 100;
const throttled = throttle(mockFn, waitTime);
throttled('call1');
expect(mockFn).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(waitTime);
throttled('call2');
expect(mockFn).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(waitTime);
throttled('call3');
expect(mockFn).toHaveBeenCalledTimes(3);
});
});
});