From a2b713b8c7c4a21bd510cffa5fef2027f8b39134 Mon Sep 17 00:00:00 2001 From: shtse8 Date: Mon, 18 Mar 2024 04:20:18 +0800 Subject: [PATCH] Update imports and remove fn-opts.test.ts --- src/{fn-ops.ts => func.ts} | 111 ++++++++++-- src/index.ts | 4 +- tests/fn-opts.test.ts | 37 ---- tests/func.test.ts | 338 +++++++++++++++++++++++++++++++++++++ 4 files changed, 438 insertions(+), 52 deletions(-) rename src/{fn-ops.ts => func.ts} (65%) delete mode 100644 tests/fn-opts.test.ts create mode 100644 tests/func.test.ts diff --git a/src/fn-ops.ts b/src/func.ts similarity index 65% rename from src/fn-ops.ts rename to src/func.ts index a687f30..e13f5a4 100644 --- a/src/fn-ops.ts +++ b/src/func.ts @@ -94,6 +94,9 @@ export function xor(...fns: Array<(...args: Arg * Throttles a function. * @param fn function to throttle * @param ms time in milliseconds to throttle the function + * @param options options to configure the throttle + * @param options.leading whether to run the function on the leading edge + * @param options.trailing whether to run the function on the trailing edge * @returns a throttled function * @example * const log = throttle(console.log, 1000) @@ -101,15 +104,76 @@ export function xor(...fns: Array<(...args: Arg * log('bar') // does not log 'bar' * log('baz') // does not log 'baz' * setTimeout(() => log('qux'), 1000) // logs 'qux' after 1 second + * + * @example + * const log = throttle(console.log, 1000, { leading: false, trailing: true }) + * log('foo') // does not log 'foo' + * log('bar') // does not log 'bar' + * log('baz') // does not log 'baz' + * setTimeout(() => log('qux'), 1000) // logs 'qux' after 1 second + * + * @example + * const log = throttle(console.log, 1000, { leading: true, trailing: false }) + * log('foo') // logs 'foo' + * log('bar') // does not log 'bar' + * log('baz') // does not log 'baz' + * setTimeout(() => log('qux'), 1000) // does not log 'qux' + * + * @example + * const log = throttle(console.log, 1000, { leading: false, trailing: false }) + * log('foo') // does not log 'foo' + * log('bar') // does not log 'bar' + * log('baz') // does not log 'baz' + * setTimeout(() => log('qux'), 1000) // does not log 'qux' + * */ -export function throttle(fn: (...args: Args) => void, ms: number) { - let last = 0; - return (...args: Args) => { - const now = Date.now(); - if (now - last < ms) return; - last = now; +export function throttle(fn: (...args: Args) => void, ms: number, { leading = true, trailing = true } = {}) { + let lastCallTime: number | null = null; + let lastInvokeTime: number = 0; + let timerId: ReturnType | null = null; + let lastArgs: Args | null = null; + + const invoke = (args: Args) => { + lastInvokeTime = Date.now(); fn(...args); - } + }; + + const startTimer = (args: Args) => { + if (timerId !== null) { + clearTimeout(timerId); + } + timerId = setTimeout(() => { + if (trailing && lastArgs !== null) { + invoke(lastArgs); + } + timerId = null; + lastArgs = null; + }, ms); + }; + + const shouldInvoke = (time: number) => { + if (lastCallTime === null) return true; + const timeSinceLastCall = time - lastCallTime; + const timeSinceLastInvoke = time - lastInvokeTime; + return (timeSinceLastCall >= ms) || (timeSinceLastInvoke >= ms); + }; + + return function (...args: Args) { + const now = Date.now(); + const isInvoking = shouldInvoke(now); + + lastArgs = args; + lastCallTime = now; + + if (isInvoking) { + if (leading) { + invoke(args); + } + startTimer(args); + } else if (timerId === null && trailing) { + startTimer(args); + } + }; } @@ -117,18 +181,39 @@ export function throttle(fn: (...args: Args) => * Debounces a function. * @param fn function to debounce * @param ms time in milliseconds to debounce the function + * @param options options to configure the debounce + * @param options.immediate whether to run the function immediately * @returns a debounced function * @example * const log = debounce(console.log, 1000) * log('foo') // logs 'foo' after 1 second * log('bar') // logs 'bar' after 1 second, 'foo' is not logged + * + * @example + * const log = debounce(console.log, 1000, { immediate: true }) + * log('foo') // logs 'foo' + * log('bar') // does not log 'bar' + * log('baz') // does not log 'baz' + * setTimeout(() => log('qux'), 1000) // logs 'qux' after 1 second */ -export function debounce(fn: (...args: Args) => void, ms: number) { - let timeout: Timer; - return (...args: Args) => { - clearTimeout(timeout); - timeout = setTimeout(() => fn(...args), ms); - } +export function debounce(fn: (...args: Args) => void, ms: number, { immediate = false } = {}) { + let timeoutId: ReturnType | null = null; + + return function (...args: Args) { + const callNow = immediate && timeoutId === null; + const later = () => { + timeoutId = null; + if (!immediate) fn(...args); + }; + + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(later, ms); + + if (callNow) fn(...args); + }; } diff --git a/src/index.ts b/src/index.ts index 5446a8f..2adb6f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ export * from './typed' export * from './types' -export * from './fn-ops' +export * from './func' export * from './str' export * from './misc' export * from './array' @@ -11,7 +11,7 @@ export * from './object' import * as typed from './typed' import * as types from './types' -import * as fnOps from './fn-ops' +import * as fnOps from './func' import * as str from './str' import * as misc from './misc' import * as array from './array' diff --git a/tests/fn-opts.test.ts b/tests/fn-opts.test.ts deleted file mode 100644 index 01ec725..0000000 --- a/tests/fn-opts.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, test, it, expect } from 'bun:test' -import x from '../src/index' - -describe('fn-opts', () => { - test('not', () => { - const isEven = (n: number) => n % 2 === 0 - const isOdd = x.not(isEven) - expect(isOdd(2)).toBe(false) - }) - test('and', () => { - const isEven = (n: number) => n % 2 === 0 - const isPositive = (n: number) => n > 0 - const isPositiveEven = x.and(isEven, isPositive) - expect(isPositiveEven(2)).toBe(true) - }) - test('or', () => { - const isEven = (n: number) => n % 2 === 0 - const isPositive = (n: number) => n > 0 - const isPositiveOrEven = x.or(isEven, isPositive) - expect(isPositiveOrEven(2)).toBe(true) - expect(isPositiveOrEven(3)).toBe(true) - }) - test('xor', () => { - const isEven = (n: number) => n % 2 === 0 - const isPositive = (n: number) => n > 0 - const isPositiveXorEven = x.xor(isEven, isPositive) - expect(isPositiveXorEven(2)).toBe(false) - expect(isPositiveXorEven(3)).toBe(true) - expect(isPositiveXorEven(4)).toBe(false) - }) - test('ensure', () => { - const isEven = (n: number) => n % 2 === 0 - const ensureEven = x.ensure(isEven, 'Invalid value') - expect(ensureEven(2)).toBe(2) - expect(() => ensureEven(3)).toThrow('Invalid value') - }) -}) diff --git a/tests/func.test.ts b/tests/func.test.ts new file mode 100644 index 0000000..65cbd70 --- /dev/null +++ b/tests/func.test.ts @@ -0,0 +1,338 @@ +import { describe, test, it, expect, jest, beforeEach } from 'bun:test' +import x from '../src/index' + +// not +describe('not', () => { + test("Returns false for true function", () => { + const input = () => true; + const result = x.not(input); + expect(result()).toBe(false); + }) + + test("Returns true for false function", () => { + const input = () => false; + const result = x.not(input); + expect(result()).toBe(true); + }) +}) + + +// or +describe('or', () => { + test("Returns true for true functions", () => { + const input = [() => true, () => true]; + const result = x.or(...input); + expect(result()).toBe(true); + }) + + test("Returns true for mixed functions", () => { + const input = [() => true, () => false]; + const result = x.or(...input); + expect(result()).toBe(true); + }) + + test("Returns false for false functions", () => { + const input = [() => false, () => false]; + const result = x.or(...input); + expect(result()).toBe(false); + }) +}) + +// and +describe('and', () => { + test("Returns true for true functions", () => { + const input = [() => true, () => true]; + const result = x.and(...input); + expect(result()).toBe(true); + }) + + test("Returns false for mixed functions", () => { + const input = [() => true, () => false]; + const result = x.and(...input); + expect(result()).toBe(false); + }) + + test("Returns false for false functions", () => { + const input = [() => false, () => false]; + const result = x.and(...input); + expect(result()).toBe(false); + }) +}) + + +// or +describe('or', () => { + test("Returns true for true functions", () => { + const input = [() => true, () => true]; + const result = x.or(...input); + expect(result()).toBe(true); + }) + + test("Returns true for mixed functions", () => { + const input = [() => true, () => false]; + const result = x.or(...input); + expect(result()).toBe(true); + }) + + test("Returns false for false functions", () => { + const input = [() => false, () => false]; + const result = x.or(...input); + expect(result()).toBe(false); + }) +}) + + +// xor +describe('xor', () => { + test("Returns false for true functions", () => { + const input = [() => true, () => true]; + const result = x.xor(...input); + expect(result()).toBe(false); + }) + + test("Returns true for mixed functions", () => { + const input = [() => true, () => false]; + const result = x.xor(...input); + expect(result()).toBe(true); + }) + + test("Returns false for false functions", () => { + const input = [() => false, () => false]; + const result = x.xor(...input); + expect(result()).toBe(false); + }) +}) + +// ensure +describe('ensure', () => { + test("Returns true for true function", () => { + const isArray = x.isArr; + const ensureArray = x.ensure(isArray); + const arr = [1, 2, 3]; + expect(ensureArray(arr)).toBe(arr); + }) + + test("Returns false for false function", () => { + const isArray = x.isArr; + const ensureArray = x.ensure(isArray); + expect(() => ensureArray("hello")).toThrow(); + }) +}) + +// throttle +describe('throttle', () => { + let func: jest.Mock; + let throttledFunc: Function; + const wait = 100; // Milliseconds + + beforeEach(() => { + func = jest.fn(); + }); + + // Test case 1: Throttling Function Calls + it('calls the function at most once within specified milliseconds', async () => { + throttledFunc = x.throttle(func, wait); + throttledFunc(); + throttledFunc(); + throttledFunc(); + + await new Promise((r) => setTimeout(r, wait + 50)); // Wait for throttle period + buffer + expect(func).toHaveBeenCalledTimes(2); + }); + + // Test case 2: Leading Call + it('calls function immediately if leading is true', () => { + throttledFunc = x.throttle(func, wait, { leading: true, trailing: false }); + throttledFunc(); + expect(func).toHaveBeenCalledTimes(1); + }); + + // Test case 3: Trailing Call + it('ensures function is called after the last trigger if trailing is true', async () => { + throttledFunc = x.throttle(func, wait, { trailing: true, leading: false }); + throttledFunc(); + throttledFunc(); + await new Promise((r) => setTimeout(r, wait + 50)); + expect(func).toHaveBeenCalledTimes(1); // Once for leading, once for trailing + }); + + // Test case 4: No Leading or Trailing Call + it('does not call function if both leading and trailing are false', async () => { + throttledFunc = x.throttle(func, wait, { leading: false, trailing: false }); + throttledFunc(); + await new Promise((r) => setTimeout(r, wait + 50)); + expect(func).toHaveBeenCalledTimes(0); + }); +}) + +// debounce +describe('debounce', () => { + let func: jest.Mock; + let debouncedFunc: Function; + const wait = 100; // Milliseconds + + beforeEach(() => { + func = jest.fn(); + }); + + // Test case 1: Debouncing Multiple Calls + it('executes the function only once for multiple calls within the debounce period', async () => { + debouncedFunc = x.debounce(func, wait); + debouncedFunc(); + debouncedFunc(); + debouncedFunc(); + + await new Promise((r) => setTimeout(r, wait * 1.5)); // Wait more than debounce period + expect(func).toHaveBeenCalledTimes(1); + }); + + // Test case 2: Immediate Execution + it('executes immediately on the first call when immediate is true', () => { + debouncedFunc = x.debounce(func, wait, { immediate: true }); + debouncedFunc(); + expect(func).toHaveBeenCalledTimes(1); + }); + + // Test case 3: Arguments Handling + it('calls the debounced function with the last call\'s arguments', async () => { + debouncedFunc = x.debounce(func, wait); + debouncedFunc(1); + debouncedFunc(2); + debouncedFunc(3); // This call's arguments should be used + + await new Promise((r) => setTimeout(r, wait * 1.5)); + expect(func).toHaveBeenCalledWith(3); + }); + + // Test case 4: Execution Delay + it('delays the function execution by the specified milliseconds', async () => { + const startTime = Date.now(); + debouncedFunc = x.debounce(() => { + expect(Date.now() - startTime).toBeGreaterThanOrEqual(wait); + }, wait); + debouncedFunc(); + + await new Promise((r) => setTimeout(r, wait * 1.5)); + }); + +}) + +// once +describe('once', () => { + // Test case 1: Calls the function exactly once + it('calls the function exactly once', () => { + const mockFn = jest.fn(); + const onceFn = x.once(mockFn); + + onceFn(); + onceFn(); + onceFn(); + + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + // Test case 2: Returns the correct value on the first and subsequent calls + it('returns the correct value on the first and subsequent calls', () => { + const returnValue = 'test value'; + const testFn = () => returnValue; + const onceFn = x.once(testFn); + + expect(onceFn()).toBe(returnValue); + expect(onceFn()).toBe(returnValue); + expect(onceFn()).toBe(returnValue); + }); + + // Test case 3: Handles arguments passed to the function + it('handles arguments passed to the function', () => { + const add = (a: number, b: number) => a + b; + const onceAdd = x.once(add); + + expect(onceAdd(2, 3)).toBe(5); + // Subsequent calls should return the same value regardless of the arguments + expect(onceAdd(10, 10)).toBe(5); + }); + + // Test case 4: Does not execute the function more than once + it('does not execute the function more than once', () => { + let counter = 0; + const increment = () => { counter += 1; return counter; }; + const onceIncrement = x.once(increment); + + onceIncrement(); + onceIncrement(); + onceIncrement(); + + expect(counter).toBe(1); + }); +}); + +// bind +describe('bind', () => { + // Test case 1: Binds a function to a context + it('binds a function to a context', () => { + const obj = { + name: 'foo', + greet() { + return `Hello, ${this.name}!`; + } + }; + const greet = x.bind(obj.greet, obj); + expect(greet()).toBe('Hello, foo!'); + }); + + // Test case 2: Binds a function to a different context + it('binds a function to a different context', () => { + const obj = { + name: 'foo', + greet() { + return `Hello, ${this.name}!`; + } + }; + const obj2 = { name: 'bar' }; + const greet = x.bind(obj.greet, obj2); + expect(greet()).toBe('Hello, bar!'); + }); +}); + +// memoize +describe('memoize', () => { + // Test case 1: Memoizes a function + it('memoizes a function', () => { + const add = jest.fn((a: number, b: number) => { + console.log('Calculating sum'); + return a + b; + }); + const memoizedAdd = x.memoize(add); + + memoizedAdd(1, 2); + memoizedAdd(1, 2); + memoizedAdd(2, 3); + memoizedAdd(2, 3); + + expect(add).toHaveBeenCalledTimes(2); + }); + + // Test case 2: Returns the correct value + it('returns the correct value', () => { + const add = (a: number, b: number) => a + b; + const memoizedAdd = x.memoize(add); + + expect(memoizedAdd(1, 2)).toBe(3); + expect(memoizedAdd(1, 2)).toBe(3); + expect(memoizedAdd(2, 3)).toBe(5); + expect(memoizedAdd(2, 3)).toBe(5); + }); + + // Test case 3: Handles multiple arguments + it('handles multiple arguments', () => { + const add = jest.fn((a: number, b: number, c: number) => a + b + c); + const memoizedAdd = x.memoize(add); + + memoizedAdd(1, 2, 3); + memoizedAdd(1, 2, 3); + memoizedAdd(2, 3, 4); + memoizedAdd(2, 3, 4); + + expect(add).toHaveBeenCalledTimes(2); + }); +}); \ No newline at end of file