diff --git a/src/shared/lib/helpers/createDebouncedState/createDebouncedState.test.ts b/src/shared/lib/helpers/createDebouncedState/createDebouncedState.test.ts new file mode 100644 index 0000000..5987822 --- /dev/null +++ b/src/shared/lib/helpers/createDebouncedState/createDebouncedState.test.ts @@ -0,0 +1,444 @@ +import { createDebouncedState } from '$shared/lib'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +/** + * Test Suite for createDebouncedState Helper Function + * + * This suite tests the debounced state management logic, + * including immediate vs debounced updates, timing behavior, + * and reset functionality. + */ + +describe('createDebouncedState - Basic Logic', () => { + it('creates state with initial value', () => { + const state = createDebouncedState('initial'); + + expect(state.immediate).toBe('initial'); + expect(state.debounced).toBe('initial'); + }); + + it('supports custom debounce delay', () => { + const state = createDebouncedState('test', 100); + + expect(state.immediate).toBe('test'); + expect(state.debounced).toBe('test'); + }); + + it('uses default delay of 300ms when not specified', () => { + const state = createDebouncedState('test'); + + expect(state.immediate).toBe('test'); + expect(state.debounced).toBe('test'); + }); + + it('allows updating immediate value', () => { + const state = createDebouncedState('initial'); + + state.immediate = 'updated'; + + expect(state.immediate).toBe('updated'); + }); +}); + +describe('createDebouncedState - Debounce Timing', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('immediate value updates instantly', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'updated'; + + expect(state.immediate).toBe('updated'); + expect(state.debounced).toBe('initial'); + }); + + it('debounced value updates after delay', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'updated'; + expect(state.debounced).toBe('initial'); + + vi.advanceTimersByTime(99); + expect(state.debounced).toBe('initial'); + + vi.advanceTimersByTime(1); + expect(state.debounced).toBe('updated'); + }); + + it('rapid changes reset the debounce timer', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'change1'; + vi.advanceTimersByTime(50); + + state.immediate = 'change2'; + vi.advanceTimersByTime(50); + + state.immediate = 'change3'; + vi.advanceTimersByTime(50); + + expect(state.debounced).toBe('initial'); + expect(state.immediate).toBe('change3'); + + vi.advanceTimersByTime(50); + expect(state.debounced).toBe('change3'); + }); + + it('debounced value remains unchanged during rapid updates', () => { + const state = createDebouncedState('initial', 100); + + for (let i = 0; i < 5; i++) { + state.immediate = `update${i}`; + vi.advanceTimersByTime(25); + } + + expect(state.immediate).toBe('update4'); + expect(state.debounced).toBe('initial'); + }); +}); + +describe('createDebouncedState - Reset Functionality', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('resets to initial value when called without argument', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'changed'; + vi.advanceTimersByTime(100); + + expect(state.immediate).toBe('changed'); + expect(state.debounced).toBe('changed'); + + state.reset(); + + expect(state.immediate).toBe('initial'); + expect(state.debounced).toBe('initial'); + }); + + it('resets to custom value when argument provided', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'changed'; + state.reset('custom'); + + expect(state.immediate).toBe('custom'); + expect(state.debounced).toBe('custom'); + }); + + it('resets immediately without debounce delay', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'changed'; + vi.advanceTimersByTime(50); + + state.reset(); + + expect(state.immediate).toBe('initial'); + expect(state.debounced).toBe('initial'); + + // Pending debounce from 'changed' will still fire after the delay + vi.advanceTimersByTime(50); + expect(state.debounced).toBe('changed'); + }); + + it('resets sets both values immediately', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'changed'; + vi.advanceTimersByTime(50); + + state.reset('new'); + + expect(state.immediate).toBe('new'); + expect(state.debounced).toBe('new'); + + // Pending debounce from 'changed' will fire after remaining delay + vi.advanceTimersByTime(50); + expect(state.debounced).toBe('changed'); + }); +}); + +describe('createDebouncedState - Type Support', () => { + it('works with string type', () => { + const state = createDebouncedState('hello', 100); + + state.immediate = 'world'; + + expect(state.immediate).toBe('world'); + }); + + it('works with number type', () => { + const state = createDebouncedState(0, 100); + + state.immediate = 42; + + expect(state.immediate).toBe(42); + }); + + it('works with boolean type', () => { + const state = createDebouncedState(false, 100); + + state.immediate = true; + + expect(state.immediate).toBe(true); + }); + + it('works with object type', () => { + interface TestObject { + value: number; + label: string; + } + const initial: TestObject = { value: 0, label: 'initial' }; + const state = createDebouncedState(initial, 100); + + const updated: TestObject = { value: 1, label: 'updated' }; + state.immediate = updated; + + expect(state.immediate).toBe(updated); + expect(state.immediate.value).toBe(1); + }); + + it('works with array type', () => { + const initial = [1, 2, 3]; + const state = createDebouncedState(initial, 100); + + const updated = [4, 5, 6]; + state.immediate = updated; + + expect(state.immediate).toEqual(updated); + }); + + it('works with null type', () => { + const state = createDebouncedState(null, 100); + + state.immediate = 'not null'; + + expect(state.immediate).toBe('not null'); + }); + + it('works with undefined type', () => { + const state = createDebouncedState(undefined, 100); + + state.immediate = 42; + + expect(state.immediate).toBe(42); + }); +}); + +describe('createDebouncedState - Corner Cases', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('handles empty string', () => { + const state = createDebouncedState('', 100); + + state.immediate = ''; + vi.advanceTimersByTime(100); + + expect(state.immediate).toBe(''); + expect(state.debounced).toBe(''); + }); + + it('handles zero value', () => { + const state = createDebouncedState(0, 100); + + expect(state.immediate).toBe(0); + expect(state.debounced).toBe(0); + + state.immediate = 0; + vi.advanceTimersByTime(100); + + expect(state.immediate).toBe(0); + expect(state.debounced).toBe(0); + }); + + it('handles very short debounce delay (1ms)', () => { + const state = createDebouncedState('initial', 1); + + state.immediate = 'changed'; + expect(state.debounced).toBe('initial'); + + vi.advanceTimersByTime(1); + expect(state.debounced).toBe('changed'); + }); + + it('handles very long debounce delay (5000ms)', () => { + const state = createDebouncedState('initial', 5000); + + state.immediate = 'changed'; + vi.advanceTimersByTime(4999); + + expect(state.debounced).toBe('initial'); + + vi.advanceTimersByTime(1); + expect(state.debounced).toBe('changed'); + }); + + it('handles setting to same value multiple times', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'same'; + vi.advanceTimersByTime(50); + + state.immediate = 'same'; + vi.advanceTimersByTime(50); + + expect(state.immediate).toBe('same'); + vi.advanceTimersByTime(100); + + expect(state.debounced).toBe('same'); + }); + + it('handles alternating between two values rapidly', () => { + const state = createDebouncedState('initial', 50); + + for (let i = 0; i < 5; i++) { + state.immediate = 'value1'; + vi.advanceTimersByTime(25); + state.immediate = 'value2'; + vi.advanceTimersByTime(25); + } + + expect(state.immediate).toBe('value2'); + expect(state.debounced).toBe('initial'); + + vi.advanceTimersByTime(50); + expect(state.debounced).toBe('value2'); + }); + + it('handles reset during pending debounce', () => { + const state = createDebouncedState('initial', 100); + + state.immediate = 'changed'; + vi.advanceTimersByTime(50); + + state.reset(); + + expect(state.immediate).toBe('initial'); + expect(state.debounced).toBe('initial'); + + // Pending debounce from 'changed' will fire after remaining delay + vi.advanceTimersByTime(50); + expect(state.debounced).toBe('changed'); + }); + + it('handles immediate value changes after reset', () => { + const state = createDebouncedState('initial', 100); + + state.reset('new'); + + expect(state.immediate).toBe('new'); + + state.immediate = 'newer'; + vi.advanceTimersByTime(100); + + expect(state.immediate).toBe('newer'); + expect(state.debounced).toBe('newer'); + }); +}); + +describe('createDebouncedState - Multiple Instances', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('handles multiple independent instances', () => { + const state1 = createDebouncedState('one', 100); + const state2 = createDebouncedState('two', 100); + + state1.immediate = 'changed1'; + state2.immediate = 'changed2'; + + expect(state1.immediate).toBe('changed1'); + expect(state2.immediate).toBe('changed2'); + + vi.advanceTimersByTime(100); + + expect(state1.debounced).toBe('changed1'); + expect(state2.debounced).toBe('changed2'); + }); + + it('independent timers for each instance', () => { + const state1 = createDebouncedState('one', 100); + const state2 = createDebouncedState('two', 200); + + state1.immediate = 'changed1'; + state2.immediate = 'changed2'; + + vi.advanceTimersByTime(100); + + expect(state1.debounced).toBe('changed1'); + expect(state2.debounced).toBe('two'); + + vi.advanceTimersByTime(100); + + expect(state2.debounced).toBe('changed2'); + }); +}); + +describe('createDebouncedState - Interface Compliance', () => { + it('exposes immediate getter', () => { + const state = createDebouncedState('test'); + + expect(() => { + const _ = state.immediate; + }).not.toThrow(); + }); + + it('exposes immediate setter', () => { + const state = createDebouncedState('test'); + + expect(() => { + state.immediate = 'new'; + }).not.toThrow(); + }); + + it('exposes debounced getter', () => { + const state = createDebouncedState('test'); + + expect(() => { + const _ = state.debounced; + }).not.toThrow(); + }); + + it('exposes reset method', () => { + const state = createDebouncedState('test'); + + expect(typeof state.reset).toBe('function'); + }); + + it('does not expose debounced setter', () => { + const state = createDebouncedState('test'); + + // TypeScript should prevent this, but we can check the runtime behavior + expect(state).not.toHaveProperty('set debounced'); + }); +});