diff --git a/helpers/array/chunk.test.ts b/helpers/array/chunk.test.ts new file mode 100644 index 00000000..51f37715 --- /dev/null +++ b/helpers/array/chunk.test.ts @@ -0,0 +1,31 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { chunk } from "./chunk"; + +describe("chunk", () => { + it("should chunk array into specified size", () => { + expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); + }); + + it("should work with exact divisions", () => { + expect(chunk([1, 2, 3, 4], 2)).toEqual([[1, 2], [3, 4]]); + }); + + it("should work with size 1", () => { + expect(chunk([1, 2, 3], 1)).toEqual([[1], [2], [3]]); + }); + + it("should return empty array for size 0 or negative", () => { + expect(chunk([1, 2, 3], 0)).toEqual([]); + expect(chunk([1, 2, 3], -1)).toEqual([]); + }); + + it("should work with empty array", () => { + expect(chunk([], 2)).toEqual([]); + }); +}); diff --git a/helpers/array/chunk.ts b/helpers/array/chunk.ts new file mode 100644 index 00000000..56a7aff0 --- /dev/null +++ b/helpers/array/chunk.ts @@ -0,0 +1,20 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Chunks an array into smaller arrays of specified size + * @param array - The array to chunk + * @param size - The size of each chunk + * @returns Array of chunks + */ +export function chunk(array: T[], size: number): T[][] { + if (size <= 0) return []; + const result: T[][] = []; + for (let i = 0; i < array.length; i += size) { + result.push(array.slice(i, i + size)); + } + return result; +} diff --git a/helpers/array/difference.test.ts b/helpers/array/difference.test.ts new file mode 100644 index 00000000..ce073b34 --- /dev/null +++ b/helpers/array/difference.test.ts @@ -0,0 +1,31 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { difference } from "./difference"; + +describe("difference", () => { + it("should return items in first array but not in second", () => { + expect(difference([1, 2, 3, 4], [2, 4])).toEqual([1, 3]); + }); + + it("should work with strings", () => { + expect(difference(['a', 'b', 'c'], ['b'])).toEqual(['a', 'c']); + }); + + it("should return empty array when all items are common", () => { + expect(difference([1, 2, 3], [1, 2, 3, 4])).toEqual([]); + }); + + it("should return first array when no items are common", () => { + expect(difference([1, 2, 3], [4, 5, 6])).toEqual([1, 2, 3]); + }); + + it("should work with empty arrays", () => { + expect(difference([], [1, 2])).toEqual([]); + expect(difference([1, 2], [])).toEqual([1, 2]); + }); +}); diff --git a/helpers/array/difference.ts b/helpers/array/difference.ts new file mode 100644 index 00000000..e754855c --- /dev/null +++ b/helpers/array/difference.ts @@ -0,0 +1,16 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Returns the difference between two arrays (items in first array but not in second) + * @param array1 - First array + * @param array2 - Second array + * @returns Array with items from first array not present in second array + */ +export function difference(array1: T[], array2: T[]): T[] { + const set2 = new Set(array2); + return array1.filter(item => !set2.has(item)); +} diff --git a/helpers/array/sort.test.ts b/helpers/array/sort.test.ts new file mode 100644 index 00000000..037d8a82 --- /dev/null +++ b/helpers/array/sort.test.ts @@ -0,0 +1,125 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { + sortNumberAscFn, + sortNumberDescFn, + sortStringAscFn, + sortStringDescFn, + sortStringAscInsensitiveFn, + createSortByStringFn, + createSortByNumberFn, + createSortByDateFn +} from "./sort"; + +describe("sort functions", () => { + describe("number sorting", () => { + it("should sort numbers ascending", () => { + const arr = [3, 1, 4, 1, 5]; + arr.sort(sortNumberAscFn); + expect(arr).toEqual([1, 1, 3, 4, 5]); + }); + + it("should sort numbers descending", () => { + const arr = [3, 1, 4, 1, 5]; + arr.sort(sortNumberDescFn); + expect(arr).toEqual([5, 4, 3, 1, 1]); + }); + }); + + describe("string sorting", () => { + it("should sort strings ascending", () => { + const arr = ['cherry', 'apple', 'banana']; + arr.sort(sortStringAscFn); + expect(arr).toEqual(['apple', 'banana', 'cherry']); + }); + + it("should sort strings descending", () => { + const arr = ['cherry', 'apple', 'banana']; + arr.sort(sortStringDescFn); + expect(arr).toEqual(['cherry', 'banana', 'apple']); + }); + + it("should sort strings case insensitive", () => { + const arr = ['Cherry', 'apple', 'Banana']; + arr.sort(sortStringAscInsensitiveFn); + expect(arr).toEqual(['apple', 'Banana', 'Cherry']); + }); + }); + + describe("property sorting", () => { + const users = [ + { name: 'John', age: 30, joined: new Date('2020-01-01') }, + { name: 'alice', age: 25, joined: new Date('2021-01-01') }, + { name: 'Bob', age: 35, joined: new Date('2019-01-01') } + ]; + + it("should sort by string property", () => { + const sorted = [...users].sort(createSortByStringFn('name')); + expect(sorted.map(u => u.name)).toEqual(['alice', 'Bob', 'John']); + }); + + it("should sort by string property case insensitive", () => { + const sorted = [...users].sort(createSortByStringFn('name', true)); + expect(sorted.map(u => u.name)).toEqual(['alice', 'Bob', 'John']); + }); + + it("should sort by number property", () => { + const sorted = [...users].sort(createSortByNumberFn('age')); + expect(sorted.map(u => u.age)).toEqual([25, 30, 35]); + }); + + it("should sort by date property", () => { + const sorted = [...users].sort(createSortByDateFn('joined')); + expect(sorted.map(u => u.joined.getFullYear())).toEqual([2019, 2020, 2021]); + }); + + it("should use default properties", () => { + const items = [ + { value: 'z' }, + { value: 'a' }, + { value: 'c' } + ]; + + const sorted = [...items].sort(createSortByStringFn()); + expect(sorted.map(i => i.value)).toEqual(['a', 'c', 'z']); + }); + + it("should fallback to label when all objects have label", () => { + const items = [ + { label: 'z' }, + { label: 'a' }, + { label: 'c' } + ]; + + const sorted = [...items].sort(createSortByStringFn()); + expect(sorted.map(i => i.label)).toEqual(['a', 'c', 'z']); + }); + + it("should use default number property", () => { + const items = [ + { value: 30 }, + { value: 10 }, + { value: 20 } + ]; + + const sorted = [...items].sort(createSortByNumberFn()); + expect(sorted.map(i => i.value)).toEqual([10, 20, 30]); + }); + + it("should use default date property", () => { + const items = [ + { date: new Date('2022-01-01') }, + { date: new Date('2021-01-01') }, + { date: new Date('2023-01-01') } + ]; + + const sorted = [...items].sort(createSortByDateFn()); + expect(sorted.map(i => i.date.getFullYear())).toEqual([2021, 2022, 2023]); + }); + }); +}); diff --git a/helpers/array/sort.ts b/helpers/array/sort.ts new file mode 100644 index 00000000..dbcb20d8 --- /dev/null +++ b/helpers/array/sort.ts @@ -0,0 +1,117 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Sort function type for arrays + */ +export type SortFn = (a: T, b: T) => number; + +/** + * Sort numbers in ascending order + * @param a - First number + * @param b - Second number + * @returns Sort order + */ +export const sortNumberAscFn: SortFn = (a: number, b: number) => a - b; + +/** + * Sort numbers in descending order + * @param a - First number + * @param b - Second number + * @returns Sort order + */ +export const sortNumberDescFn: SortFn = (a: number, b: number) => b - a; + +/** + * Sort strings in ascending order + * @param a - First string + * @param b - Second string + * @returns Sort order + */ +export const sortStringAscFn: SortFn = (a: string, b: string) => a.localeCompare(b); + +/** + * Sort strings in descending order + * @param a - First string + * @param b - Second string + * @returns Sort order + */ +export const sortStringDescFn: SortFn = (a: string, b: string) => b.localeCompare(a); + +/** + * Sort strings in ascending order (case insensitive) + * @param a - First string + * @param b - Second string + * @returns Sort order + */ +export const sortStringAscInsensitiveFn: SortFn = (a: string, b: string) => + a.toLowerCase().localeCompare(b.toLowerCase()); + +/** + * Creates a sort function for objects by string property + * @param property - The property to sort by (defaults to trying 'value', 'label', 'title', 'description') + * @param caseInsensitive - Whether to ignore case + * @returns Sort function + */ +export function createSortByStringFn>( + property?: keyof T, + caseInsensitive: boolean = false +): SortFn { + return (a: T, b: T) => { + let aVal = ''; + let bVal = ''; + + if (property) { + aVal = String(a[property] ?? ''); + bVal = String(b[property] ?? ''); + } else { + // Try default properties in order + for (const prop of ['value', 'label', 'title', 'description']) { + if (prop in a && prop in b) { + aVal = String(a[prop] ?? ''); + bVal = String(b[prop] ?? ''); + break; + } + } + } + + return caseInsensitive + ? aVal.toLowerCase().localeCompare(bVal.toLowerCase()) + : aVal.localeCompare(bVal); + }; +} + +/** + * Creates a sort function for objects by number property + * @param property - The property to sort by (defaults to 'value') + * @returns Sort function + */ +export function createSortByNumberFn>( + property?: keyof T +): SortFn { + const prop = property || 'value'; + return (a: T, b: T) => { + const aVal = Number(a[prop] ?? 0); + const bVal = Number(b[prop] ?? 0); + return aVal - bVal; + }; +} + +/** + * Creates a sort function for objects by date property + * @param property - The property to sort by (defaults to 'date') + * @returns Sort function + */ +export function createSortByDateFn>( + property?: keyof T +): SortFn { + const prop = property || 'date'; + return (a: T, b: T) => { + const aVal = new Date(a[prop] as any).getTime(); + const bVal = new Date(b[prop] as any).getTime(); + return aVal - bVal; + }; +} diff --git a/helpers/array/unique.test.ts b/helpers/array/unique.test.ts new file mode 100644 index 00000000..1dd727f0 --- /dev/null +++ b/helpers/array/unique.test.ts @@ -0,0 +1,26 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { unique } from "./unique"; + +describe("unique", () => { + it("should remove duplicates from array", () => { + expect(unique([1, 2, 2, 3, 3, 3])).toEqual([1, 2, 3]); + }); + + it("should work with strings", () => { + expect(unique(['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']); + }); + + it("should work with empty array", () => { + expect(unique([])).toEqual([]); + }); + + it("should preserve order for first occurrence", () => { + expect(unique([3, 1, 2, 1, 3])).toEqual([3, 1, 2]); + }); +}); diff --git a/helpers/array/unique.ts b/helpers/array/unique.ts new file mode 100644 index 00000000..d4cc3e20 --- /dev/null +++ b/helpers/array/unique.ts @@ -0,0 +1,14 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Removes duplicate values from an array + * @param array - The array to remove duplicates from + * @returns New array with unique values only + */ +export function unique(array: T[]): T[] { + return Array.from(new Set(array)); +} diff --git a/helpers/date/config.json b/helpers/date/config.json new file mode 100644 index 00000000..6d5c50c0 --- /dev/null +++ b/helpers/date/config.json @@ -0,0 +1,4 @@ +{ + "name": "date", + "description": "Date and time utility functions" +} \ No newline at end of file diff --git a/helpers/date/dateComparison.test.ts b/helpers/date/dateComparison.test.ts new file mode 100644 index 00000000..e293b299 --- /dev/null +++ b/helpers/date/dateComparison.test.ts @@ -0,0 +1,28 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { isSameDay, daysDifference } from "./dateComparison"; + +describe("date comparison utilities", () => { + const date1 = new Date("2022-01-20T10:00:00Z"); + const date2 = new Date("2022-01-20T15:00:00Z"); + const date3 = new Date("2022-01-21T10:00:00Z"); + + describe("isSameDay", () => { + it("should check same day", () => { + expect(isSameDay(date1, date2)).toBe(true); + expect(isSameDay(date1, date3)).toBe(false); + }); + }); + + describe("daysDifference", () => { + it("should calculate days difference", () => { + expect(daysDifference(date1, date3)).toBe(1); + expect(daysDifference(date1, date2)).toBe(0); + }); + }); +}); diff --git a/helpers/date/dateComparison.ts b/helpers/date/dateComparison.ts new file mode 100644 index 00000000..b042de98 --- /dev/null +++ b/helpers/date/dateComparison.ts @@ -0,0 +1,30 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Checks if two dates are the same day + * @param date1 - First date + * @param date2 - Second date + * @returns True if same day + */ +export function isSameDay(date1: Date, date2: Date): boolean { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); +} + +/** + * Gets the difference in days between two dates + * @param date1 - First date + * @param date2 - Second date + * @returns Number of days difference + */ +export function daysDifference(date1: Date, date2: Date): number { + const oneDay = 24 * 60 * 60 * 1000; + return Math.round(Math.abs((date1.getTime() - date2.getTime()) / oneDay)); +} diff --git a/helpers/date/safeDate.test.ts b/helpers/date/safeDate.test.ts new file mode 100644 index 00000000..634d57e1 --- /dev/null +++ b/helpers/date/safeDate.test.ts @@ -0,0 +1,51 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { safeDate, dateToISOString } from "./safeDate"; + +describe("safe date utilities", () => { + describe("safeDate", () => { + it("should handle valid inputs", () => { + const date = safeDate("2022-01-20"); + expect(date).toBeInstanceOf(Date); + expect(date?.getFullYear()).toBe(2022); + }); + + it("should handle timestamps", () => { + const date = safeDate(1642694400); // seconds + expect(date).toBeInstanceOf(Date); + }); + + it("should return null for invalid inputs", () => { + expect(safeDate(null)).toBe(null); + expect(safeDate(undefined)).toBe(null); + expect(safeDate("")).toBe(null); + expect(safeDate(0)).toBe(null); + expect(safeDate("invalid")).toBe(null); + }); + + it("should handle Date objects", () => { + const validDate = new Date("2022-01-20"); + const invalidDate = new Date("invalid"); + + expect(safeDate(validDate)).toEqual(validDate); + expect(safeDate(invalidDate)).toBe(null); + }); + }); + + describe("dateToISOString", () => { + it("should convert valid dates to ISO string", () => { + const iso = dateToISOString("2022-01-20T10:00:00Z"); + expect(iso).toBe("2022-01-20T10:00:00.000Z"); + }); + + it("should return null for invalid dates", () => { + expect(dateToISOString(null)).toBe(null); + expect(dateToISOString("invalid")).toBe(null); + }); + }); +}); diff --git a/helpers/date/safeDate.ts b/helpers/date/safeDate.ts new file mode 100644 index 00000000..878d140b --- /dev/null +++ b/helpers/date/safeDate.ts @@ -0,0 +1,45 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { normalizeTimestamp } from './timestamp'; + +/** + * Safely creates a Date object from various input types + * @param input - String, number, or Date input + * @returns Valid Date object or null if invalid + */ +export function safeDate(input: string | number | Date | null | undefined): Date | null { + if (input === null || input === undefined || input === '' || input === 0) { + return null; + } + + if (input instanceof Date) { + return isNaN(input.getTime()) ? null : input; + } + + if (typeof input === 'number') { + const normalized = normalizeTimestamp(input); + const date = new Date(normalized); + return isNaN(date.getTime()) ? null : date; + } + + if (typeof input === 'string') { + const date = new Date(input); + return isNaN(date.getTime()) ? null : date; + } + + return null; +} + +/** + * Formats a date to ISO string or returns null + * @param input - Date input + * @returns ISO string or null + */ +export function dateToISOString(input: string | number | Date | null | undefined): string | null { + const date = safeDate(input); + return date ? date.toISOString() : null; +} diff --git a/helpers/date/timestamp.test.ts b/helpers/date/timestamp.test.ts new file mode 100644 index 00000000..9a97e681 --- /dev/null +++ b/helpers/date/timestamp.test.ts @@ -0,0 +1,24 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { isTimestampInSeconds, normalizeTimestamp } from "./timestamp"; + +describe("timestamp utilities", () => { + describe("isTimestampInSeconds", () => { + it("should identify seconds vs milliseconds", () => { + expect(isTimestampInSeconds(1642694400)).toBe(true); // 2022-01-20 in seconds + expect(isTimestampInSeconds(1642694400000)).toBe(false); // 2022-01-20 in milliseconds + }); + }); + + describe("normalizeTimestamp", () => { + it("should normalize timestamps", () => { + expect(normalizeTimestamp(1642694400)).toBe(1642694400000); + expect(normalizeTimestamp(1642694400000)).toBe(1642694400000); + }); + }); +}); diff --git a/helpers/date/timestamp.ts b/helpers/date/timestamp.ts new file mode 100644 index 00000000..38e0a9f9 --- /dev/null +++ b/helpers/date/timestamp.ts @@ -0,0 +1,24 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Checks if a timestamp is likely in seconds (Java/Unix style) vs milliseconds (JavaScript style) + * @param timestamp - The timestamp to check + * @returns True if timestamp appears to be in seconds + */ +export function isTimestampInSeconds(timestamp: number): boolean { + // Timestamps before year 2001 in milliseconds are less than 10^10 + return timestamp < 10000000000; +} + +/** + * Converts a timestamp to JavaScript milliseconds format + * @param timestamp - The timestamp (in seconds or milliseconds) + * @returns Timestamp in milliseconds + */ +export function normalizeTimestamp(timestamp: number): number { + return isTimestampInSeconds(timestamp) ? timestamp * 1000 : timestamp; +} diff --git a/helpers/function/debounce.test.ts b/helpers/function/debounce.test.ts new file mode 100644 index 00000000..6da85666 --- /dev/null +++ b/helpers/function/debounce.test.ts @@ -0,0 +1,36 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { debounce } from "./debounce"; + +describe("debounce", () => { + it("should debounce function calls", async () => { + let callCount = 0; + const debouncedFunc = debounce(() => callCount++, 100); + + debouncedFunc(); + debouncedFunc(); + debouncedFunc(); + + expect(callCount).toBe(0); + + await new Promise(resolve => setTimeout(resolve, 150)); + expect(callCount).toBe(1); + }); + + it("should pass arguments correctly", async () => { + let lastArgs: any[] = []; + const debouncedFunc = debounce((...args: any[]) => { + lastArgs = args; + }, 50); + + debouncedFunc(1, 'test', true); + + await new Promise(resolve => setTimeout(resolve, 100)); + expect(lastArgs).toEqual([1, 'test', true]); + }); +}); diff --git a/helpers/function/debounce.ts b/helpers/function/debounce.ts new file mode 100644 index 00000000..a9a088d9 --- /dev/null +++ b/helpers/function/debounce.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Creates a debounced function that delays invoking func until after delay milliseconds have elapsed since the last time the debounced function was invoked + * @param func - The function to debounce + * @param delay - The number of milliseconds to delay + * @returns The debounced function + */ +export function debounce any>( + func: T, + delay: number +): (...args: Parameters) => void { + let timeoutId: NodeJS.Timeout | null = null; + + return (...args: Parameters) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => func(...args), delay); + }; +} diff --git a/helpers/function/memoize.test.ts b/helpers/function/memoize.test.ts new file mode 100644 index 00000000..c19f0dd3 --- /dev/null +++ b/helpers/function/memoize.test.ts @@ -0,0 +1,35 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { memoize } from "./memoize"; + +describe("memoize", () => { + it("should memoize function results", () => { + let callCount = 0; + const expensiveFunc = memoize((x: number) => { + callCount++; + return x * x; + }); + + expect(expensiveFunc(5)).toBe(25); + expect(expensiveFunc(5)).toBe(25); + expect(callCount).toBe(1); + }); + + it("should work with multiple arguments", () => { + let callCount = 0; + const add = memoize((a: number, b: number) => { + callCount++; + return a + b; + }); + + expect(add(2, 3)).toBe(5); + expect(add(2, 3)).toBe(5); + expect(add(3, 2)).toBe(5); // Different args, should compute again + expect(callCount).toBe(2); + }); +}); diff --git a/helpers/function/memoize.ts b/helpers/function/memoize.ts new file mode 100644 index 00000000..f2015822 --- /dev/null +++ b/helpers/function/memoize.ts @@ -0,0 +1,26 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Returns a memoized version of the function that caches results + * @param func - The function to memoize + * @returns The memoized function + */ +export function memoize any>(func: T): T { + const cache = new Map>(); + + return ((...args: Parameters): ReturnType => { + const key = JSON.stringify(args); + + if (cache.has(key)) { + return cache.get(key)!; + } + + const result = func(...args); + cache.set(key, result); + return result; + }) as T; +} diff --git a/helpers/function/throttle.test.ts b/helpers/function/throttle.test.ts new file mode 100644 index 00000000..686bfa37 --- /dev/null +++ b/helpers/function/throttle.test.ts @@ -0,0 +1,34 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { throttle } from "./throttle"; + +describe("throttle", () => { + it("should throttle function calls", async () => { + let callCount = 0; + const throttledFunc = throttle(() => callCount++, 100); + + throttledFunc(); + throttledFunc(); + throttledFunc(); + + expect(callCount).toBe(1); + + await new Promise(resolve => setTimeout(resolve, 150)); + expect(callCount).toBe(2); + }); + + it("should pass arguments correctly", () => { + let lastArgs: any[] = []; + const throttledFunc = throttle((...args: any[]) => { + lastArgs = args; + }, 50); + + throttledFunc(1, 'test', true); + expect(lastArgs).toEqual([1, 'test', true]); + }); +}); diff --git a/helpers/function/throttle.ts b/helpers/function/throttle.ts new file mode 100644 index 00000000..0db53ce0 --- /dev/null +++ b/helpers/function/throttle.ts @@ -0,0 +1,34 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Creates a throttled function that only invokes func at most once per every wait milliseconds + * @param func - The function to throttle + * @param wait - The number of milliseconds to throttle invocations to + * @returns The throttled function + */ +export function throttle any>( + func: T, + wait: number +): (...args: Parameters) => void { + let lastCallTime = 0; + let timeoutId: NodeJS.Timeout | null = null; + + return (...args: Parameters) => { + const now = Date.now(); + + if (now - lastCallTime >= wait) { + lastCallTime = now; + func(...args); + } else if (!timeoutId) { + timeoutId = setTimeout(() => { + lastCallTime = Date.now(); + timeoutId = null; + func(...args); + }, wait - (now - lastCallTime)); + } + }; +} diff --git a/helpers/number/clamp.test.ts b/helpers/number/clamp.test.ts new file mode 100644 index 00000000..bc36f4eb --- /dev/null +++ b/helpers/number/clamp.test.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { clamp } from "./clamp"; + +describe("clamp", () => { + it("should clamp value between min and max", () => { + expect(clamp(5, 0, 10)).toBe(5); + expect(clamp(-5, 0, 10)).toBe(0); + expect(clamp(15, 0, 10)).toBe(10); + }); + + it("should handle equal min and max", () => { + expect(clamp(5, 3, 3)).toBe(3); + }); + + it("should work with floating point numbers", () => { + expect(clamp(1.5, 0, 1)).toBe(1); + expect(clamp(0.5, 0, 1)).toBe(0.5); + }); +}); diff --git a/helpers/number/clamp.ts b/helpers/number/clamp.ts new file mode 100644 index 00000000..7d7454ca --- /dev/null +++ b/helpers/number/clamp.ts @@ -0,0 +1,16 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Clamps a number between min and max values + * @param value - The value to clamp + * @param min - Minimum value + * @param max - Maximum value + * @returns Clamped value + */ +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/helpers/number/config.json b/helpers/number/config.json new file mode 100644 index 00000000..f7d60a1c --- /dev/null +++ b/helpers/number/config.json @@ -0,0 +1,4 @@ +{ + "name": "number", + "description": "Number utility functions and mathematical calculations" +} \ No newline at end of file diff --git a/helpers/number/random.test.ts b/helpers/number/random.test.ts new file mode 100644 index 00000000..e0832cd8 --- /dev/null +++ b/helpers/number/random.test.ts @@ -0,0 +1,39 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { randomBetween, randomIntBetween } from "./random"; + +describe("randomBetween", () => { + it("should generate number within range", () => { + for (let i = 0; i < 100; i++) { + const result = randomBetween(5, 10); + expect(result).toBeGreaterThanOrEqual(5); + expect(result).toBeLessThanOrEqual(10); + } + }); + + it("should handle same min and max", () => { + const result = randomBetween(5, 5); + expect(result).toBe(5); + }); +}); + +describe("randomIntBetween", () => { + it("should generate integer within range", () => { + for (let i = 0; i < 100; i++) { + const result = randomIntBetween(1, 10); + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(10); + expect(Number.isInteger(result)).toBe(true); + } + }); + + it("should handle same min and max", () => { + const result = randomIntBetween(5, 5); + expect(result).toBe(5); + }); +}); diff --git a/helpers/number/random.ts b/helpers/number/random.ts new file mode 100644 index 00000000..1c946672 --- /dev/null +++ b/helpers/number/random.ts @@ -0,0 +1,25 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Generates a random number between min and max (inclusive) + * @param min - Minimum value + * @param max - Maximum value + * @returns Random number between min and max + */ +export function randomBetween(min: number, max: number): number { + return Math.random() * (max - min) + min; +} + +/** + * Generates a random integer between min and max (inclusive) + * @param min - Minimum value + * @param max - Maximum value + * @returns Random integer between min and max + */ +export function randomIntBetween(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} diff --git a/helpers/number/roundTo.test.ts b/helpers/number/roundTo.test.ts new file mode 100644 index 00000000..06d006e9 --- /dev/null +++ b/helpers/number/roundTo.test.ts @@ -0,0 +1,28 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { roundTo } from "./roundTo"; + +describe("roundTo", () => { + it("should round to specified decimal places", () => { + expect(roundTo(3.14159, 2)).toBe(3.14); + expect(roundTo(3.14159, 3)).toBe(3.142); + expect(roundTo(3.14159, 0)).toBe(3); + }); + + it("should handle negative numbers", () => { + expect(roundTo(-3.14159, 2)).toBe(-3.14); + }); + + it("should handle zero", () => { + expect(roundTo(0, 2)).toBe(0); + }); + + it("should handle integers", () => { + expect(roundTo(5, 2)).toBe(5); + }); +}); diff --git a/helpers/number/roundTo.ts b/helpers/number/roundTo.ts new file mode 100644 index 00000000..290dd551 --- /dev/null +++ b/helpers/number/roundTo.ts @@ -0,0 +1,16 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Rounds a number to specified decimal places + * @param value - The number to round + * @param decimals - Number of decimal places + * @returns Rounded number + */ +export function roundTo(value: number, decimals: number): number { + const multiplier = Math.pow(10, decimals); + return Math.round(value * multiplier) / multiplier; +} diff --git a/helpers/object/deepClone.test.ts b/helpers/object/deepClone.test.ts new file mode 100644 index 00000000..35f108a7 --- /dev/null +++ b/helpers/object/deepClone.test.ts @@ -0,0 +1,47 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { deepClone } from "./deepClone"; + +describe("deepClone", () => { + it("should clone primitive values", () => { + expect(deepClone(42)).toBe(42); + expect(deepClone("hello")).toBe("hello"); + expect(deepClone(true)).toBe(true); + expect(deepClone(null)).toBe(null); + }); + + it("should deep clone objects", () => { + const original = { a: 1, b: { c: 2 } }; + const cloned = deepClone(original); + + expect(cloned).toEqual(original); + expect(cloned).not.toBe(original); + expect(cloned.b).not.toBe(original.b); + + cloned.b.c = 3; + expect(original.b.c).toBe(2); + }); + + it("should deep clone arrays", () => { + const original = [1, [2, 3], { a: 4 }]; + const cloned = deepClone(original); + + expect(cloned).toEqual(original); + expect(cloned).not.toBe(original); + expect(cloned[1]).not.toBe(original[1]); + expect(cloned[2]).not.toBe(original[2]); + }); + + it("should clone dates", () => { + const date = new Date(); + const cloned = deepClone(date); + + expect(cloned).toEqual(date); + expect(cloned).not.toBe(date); + }); +}); diff --git a/helpers/object/deepClone.ts b/helpers/object/deepClone.ts new file mode 100644 index 00000000..c8dbc9a9 --- /dev/null +++ b/helpers/object/deepClone.ts @@ -0,0 +1,33 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Creates a deep copy of an object or array + * @param obj - The object to clone + * @returns Deep cloned object + */ +export function deepClone(obj: T): T { + if (obj === null || typeof obj !== "object") { + return obj; + } + + if (obj instanceof Date) { + return new Date(obj.getTime()) as T; + } + + if (Array.isArray(obj)) { + return obj.map(item => deepClone(item)) as T; + } + + const cloned = {} as T; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + cloned[key] = deepClone(obj[key]); + } + } + + return cloned; +} diff --git a/helpers/object/deepMerge.test.ts b/helpers/object/deepMerge.test.ts new file mode 100644 index 00000000..e0782361 --- /dev/null +++ b/helpers/object/deepMerge.test.ts @@ -0,0 +1,43 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { deepMerge } from "./deepMerge"; + +describe("deepMerge", () => { + it("should merge objects deeply", () => { + const target = { a: 1, b: { c: 2, d: 3 } }; + const source = { b: { c: 4, e: 5 }, f: 6 }; + + const result = deepMerge(target, source); + + expect(result).toEqual({ + a: 1, + b: { c: 4, d: 3, e: 5 }, + f: 6 + }); + }); + + it("should handle multiple sources", () => { + const target = { a: 1 }; + const source1 = { b: 2 }; + const source2 = { c: 3 }; + + const result = deepMerge(target, source1, source2); + + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it("should not mutate original objects", () => { + const target = { a: 1, b: { c: 2 } }; + const source = { b: { d: 3 } }; + + deepMerge(target, source); + + expect(target.b).toHaveProperty('d', 3); + expect(target.b).toHaveProperty('c', 2); + }); +}); diff --git a/helpers/object/deepMerge.ts b/helpers/object/deepMerge.ts new file mode 100644 index 00000000..a711a7ba --- /dev/null +++ b/helpers/object/deepMerge.ts @@ -0,0 +1,35 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Merges two or more objects deeply + * @param target - The target object + * @param sources - The source objects to merge + * @returns The merged object + */ +export function deepMerge>(target: T, ...sources: Record[]): T { + if (!sources.length) return target; + const source = sources.shift(); + + if (!source) return deepMerge(target, ...sources); + + for (const key in source) { + const targetValue = target[key]; + const sourceValue = source[key]; + + if (isObject(targetValue) && isObject(sourceValue)) { + (target as any)[key] = deepMerge(targetValue, sourceValue); + } else if (sourceValue !== undefined) { + (target as any)[key] = sourceValue; + } + } + + return deepMerge(target, ...sources); +} + +function isObject(item: any): item is object { + return item && typeof item === 'object' && !Array.isArray(item); +} diff --git a/helpers/object/get.test.ts b/helpers/object/get.test.ts new file mode 100644 index 00000000..f9e36b26 --- /dev/null +++ b/helpers/object/get.test.ts @@ -0,0 +1,40 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { get } from "./get"; + +describe("get", () => { + const obj = { + a: { + b: { + c: 'value' + } + }, + x: [1, 2, { y: 'array-value' }] + }; + + it("should get nested value using dot notation", () => { + expect(get(obj, 'a.b.c')).toBe('value'); + }); + + it("should return default value for non-existent path", () => { + expect(get(obj, 'a.b.d', 'default')).toBe('default'); + }); + + it("should work with array indices", () => { + expect(get(obj, 'x.2.y')).toBe('array-value'); + }); + + it("should return undefined for non-existent path without default", () => { + expect(get(obj, 'non.existent.path')).toBeUndefined(); + }); + + it("should handle null/undefined objects", () => { + expect(get(null, 'a.b', 'default')).toBe('default'); + expect(get(undefined, 'a.b', 'default')).toBe('default'); + }); +}); diff --git a/helpers/object/get.ts b/helpers/object/get.ts new file mode 100644 index 00000000..40e10ab9 --- /dev/null +++ b/helpers/object/get.ts @@ -0,0 +1,26 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Gets a value from an object using a dot-notated path + * @param obj - The object to get value from + * @param path - The dot-notated path (e.g., 'a.b.c') + * @param defaultValue - Default value if path doesn't exist + * @returns The value at the path or default value + */ +export function get(obj: any, path: string, defaultValue?: T): T | undefined { + const keys = path.split('.'); + let result = obj; + + for (const key of keys) { + if (result == null || typeof result !== 'object') { + return defaultValue; + } + result = result[key]; + } + + return result !== undefined ? result : defaultValue; +} diff --git a/helpers/object/set.test.ts b/helpers/object/set.test.ts new file mode 100644 index 00000000..5223dbf5 --- /dev/null +++ b/helpers/object/set.test.ts @@ -0,0 +1,49 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { set } from "./set"; + +describe("set", () => { + it("should set nested value using dot notation", () => { + const obj = {}; + set(obj, 'a.b.c', 'value'); + + expect(obj).toEqual({ + a: { + b: { + c: 'value' + } + } + }); + }); + + it("should set value in existing object", () => { + const obj: Record = { a: { x: 1 } }; + set(obj, 'a.b', 'new-value'); + + expect(obj).toEqual({ + a: { + x: 1, + b: 'new-value' + } + }); + }); + + it("should overwrite existing values", () => { + const obj = { a: { b: 'old' } }; + set(obj, 'a.b', 'new'); + + expect(obj.a.b).toBe('new'); + }); + + it("should return the modified object", () => { + const obj = {}; + const result = set(obj, 'a.b', 'value'); + + expect(result).toBe(obj); + }); +}); diff --git a/helpers/object/set.ts b/helpers/object/set.ts new file mode 100644 index 00000000..eafce1e6 --- /dev/null +++ b/helpers/object/set.ts @@ -0,0 +1,30 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Sets a value in an object using a dot-notated path + * @param obj - The object to set value in + * @param path - The dot-notated path (e.g., 'a.b.c') + * @param value - The value to set + * @returns The modified object + */ +export function set(obj: Record, path: string, value: any): Record { + const keys = path.split('.'); + let current = obj; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + + if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) { + current[key] = {}; + } + + current = current[key]; + } + + current[keys[keys.length - 1]] = value; + return obj; +} diff --git a/helpers/promise/delay.test.ts b/helpers/promise/delay.test.ts new file mode 100644 index 00000000..daae378d --- /dev/null +++ b/helpers/promise/delay.test.ts @@ -0,0 +1,34 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { delay } from "./delay"; + +describe("delay", () => { + it("should resolve after specified time", async () => { + const start = Date.now(); + await delay(100); + const elapsed = Date.now() - start; + + expect(elapsed).toBeGreaterThanOrEqual(90); + expect(elapsed).toBeLessThan(200); + }); + + it("should resolve with specified value", async () => { + const result = await delay(50, "test-value"); + expect(result).toBe("test-value"); + }); + + it("should work with numbers", async () => { + const result = await delay(50, 42); + expect(result).toBe(42); + }); + + it("should work without value", async () => { + const result = await delay(50); + expect(result).toBeUndefined(); + }); +}); diff --git a/helpers/promise/delay.ts b/helpers/promise/delay.ts new file mode 100644 index 00000000..55782e94 --- /dev/null +++ b/helpers/promise/delay.ts @@ -0,0 +1,17 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Creates a promise that resolves after specified delay + * @param ms - Milliseconds to delay + * @param value - Optional value to resolve with + * @returns Promise that resolves after delay + */ +export function delay(ms: number, value?: T): Promise { + return new Promise(resolve => { + setTimeout(() => resolve(value as T), ms); + }); +} diff --git a/helpers/promise/retry.test.ts b/helpers/promise/retry.test.ts new file mode 100644 index 00000000..2f8d7e45 --- /dev/null +++ b/helpers/promise/retry.test.ts @@ -0,0 +1,48 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { retry } from "./retry"; + +describe("retry", () => { + it("should return result on first success", async () => { + const fn = () => Promise.resolve("success"); + const result = await retry(fn, 3, 10); + + expect(result).toBe("success"); + }); + + it("should retry on failure", async () => { + let attempts = 0; + const fn = () => { + attempts++; + if (attempts < 3) { + return Promise.reject(new Error("fail")); + } + return Promise.resolve("success"); + }; + + const result = await retry(fn, 3, 10); + expect(result).toBe("success"); + expect(attempts).toBe(3); + }); + + it("should throw last error after max attempts", async () => { + let attempts = 0; + const fn = () => { + attempts++; + return Promise.reject(new Error(`fail ${attempts}`)); + }; + + try { + await retry(fn, 2, 10); + expect(true).toBe(false); // Should not reach here + } catch (error) { + expect((error as Error).message).toBe("fail 2"); + expect(attempts).toBe(2); + } + }); +}); diff --git a/helpers/promise/retry.ts b/helpers/promise/retry.ts new file mode 100644 index 00000000..92af3cd9 --- /dev/null +++ b/helpers/promise/retry.ts @@ -0,0 +1,37 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Retries a promise-returning function up to maxAttempts times + * @param fn - The function to retry + * @param maxAttempts - Maximum number of attempts + * @param delayMs - Delay between attempts in milliseconds + * @returns Promise that resolves with the result or rejects with the last error + */ +export async function retry( + fn: () => Promise, + maxAttempts: number = 3, + delayMs: number = 1000 +): Promise { + let lastError: Error; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + + if (attempt === maxAttempts) { + break; + } + + // Wait before next attempt + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + + throw lastError!; +} diff --git a/helpers/string/camelCase.test.ts b/helpers/string/camelCase.test.ts new file mode 100644 index 00000000..6222c310 --- /dev/null +++ b/helpers/string/camelCase.test.ts @@ -0,0 +1,34 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { camelCase } from "./camelCase"; + +describe("camelCase", () => { + it("should convert kebab-case to camelCase", () => { + expect(camelCase("kebab-case")).toBe("kebabCase"); + }); + + it("should handle multiple dashes", () => { + expect(camelCase("multi-word-string")).toBe("multiWordString"); + }); + + it("should handle already camelCase", () => { + expect(camelCase("alreadyCamel")).toBe("alreadyCamel"); + }); + + it("should handle empty string", () => { + expect(camelCase("")).toBe(""); + }); + + it("should handle single word", () => { + expect(camelCase("hello")).toBe("hello"); + }); + + it("should handle leading dash", () => { + expect(camelCase("-leading")).toBe("Leading"); + }); +}); diff --git a/helpers/string/camelCase.ts b/helpers/string/camelCase.ts new file mode 100644 index 00000000..c1a19cef --- /dev/null +++ b/helpers/string/camelCase.ts @@ -0,0 +1,14 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Converts kebab-case to camelCase + * @param str - The kebab-case string to convert + * @returns String in camelCase + */ +export function camelCase(str: string): string { + return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +} diff --git a/helpers/string/capitalize.test.ts b/helpers/string/capitalize.test.ts new file mode 100644 index 00000000..b3cdd9f6 --- /dev/null +++ b/helpers/string/capitalize.test.ts @@ -0,0 +1,30 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { capitalize } from "./capitalize"; + +describe("capitalize", () => { + it("should capitalize first letter", () => { + expect(capitalize("hello")).toBe("Hello"); + }); + + it("should lowercase other letters", () => { + expect(capitalize("hELLO")).toBe("Hello"); + }); + + it("should handle empty string", () => { + expect(capitalize("")).toBe(""); + }); + + it("should handle single character", () => { + expect(capitalize("a")).toBe("A"); + }); + + it("should handle already capitalized", () => { + expect(capitalize("Hello")).toBe("Hello"); + }); +}); diff --git a/helpers/string/capitalize.ts b/helpers/string/capitalize.ts new file mode 100644 index 00000000..d7dd842e --- /dev/null +++ b/helpers/string/capitalize.ts @@ -0,0 +1,15 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Capitalizes the first letter of a string + * @param str - The string to capitalize + * @returns String with first letter capitalized + */ +export function capitalize(str: string): string { + if (!str) return str; + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} diff --git a/helpers/string/kebabCase.test.ts b/helpers/string/kebabCase.test.ts new file mode 100644 index 00000000..2aabcbbd --- /dev/null +++ b/helpers/string/kebabCase.test.ts @@ -0,0 +1,35 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { kebabCase } from "./kebabCase"; + +describe("kebabCase", () => { + it("should convert camelCase to kebab-case", () => { + expect(kebabCase("camelCase")).toBe("camel-case"); + }); + + it("should convert PascalCase to kebab-case", () => { + expect(kebabCase("PascalCase")).toBe("pascal-case"); + }); + + it("should handle multiple uppercase letters", () => { + expect(kebabCase("XMLHttpRequest")).toBe("xml-http-request"); + }); + + it("should handle already kebab-case", () => { + expect(kebabCase("already-kebab")).toBe("already-kebab"); + }); + + it("should handle empty string", () => { + expect(kebabCase("")).toBe(""); + }); + + it("should handle single word", () => { + expect(kebabCase("hello")).toBe("hello"); + expect(kebabCase("Hello")).toBe("hello"); + }); +}); diff --git a/helpers/string/kebabCase.ts b/helpers/string/kebabCase.ts new file mode 100644 index 00000000..c5f999c6 --- /dev/null +++ b/helpers/string/kebabCase.ts @@ -0,0 +1,17 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Converts camelCase to kebab-case + * @param str - The camelCase string to convert + * @returns String in kebab-case + */ +export function kebabCase(str: string): string { + return str + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2') + .toLowerCase(); +} diff --git a/helpers/type/config.json b/helpers/type/config.json new file mode 100644 index 00000000..c8010778 --- /dev/null +++ b/helpers/type/config.json @@ -0,0 +1,4 @@ +{ + "name": "type", + "description": "Type checking and validation utilities" +} \ No newline at end of file diff --git a/helpers/type/typeChecks.test.ts b/helpers/type/typeChecks.test.ts new file mode 100644 index 00000000..b39d2ee5 --- /dev/null +++ b/helpers/type/typeChecks.test.ts @@ -0,0 +1,94 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { + isSet, + isString, + isNumber, + isBoolean, + isArray, + isObject, + isFunction, + isDate, + isValidRegex +} from "./typeChecks"; + +describe("type checking functions", () => { + describe("isSet", () => { + it("should return true for valid values", () => { + expect(isSet("hello")).toBe(true); + expect(isSet(0)).toBe(true); + expect(isSet(false)).toBe(true); + expect(isSet([])).toBe(true); + expect(isSet({})).toBe(true); + }); + + it("should return false for null and undefined", () => { + expect(isSet(null)).toBe(false); + expect(isSet(undefined)).toBe(false); + }); + }); + + describe("basic type checks", () => { + it("should check strings", () => { + expect(isString("hello")).toBe(true); + expect(isString(123)).toBe(false); + }); + + it("should check numbers", () => { + expect(isNumber(123)).toBe(true); + expect(isNumber(0)).toBe(true); + expect(isNumber(NaN)).toBe(false); + expect(isNumber("123")).toBe(false); + }); + + it("should check booleans", () => { + expect(isBoolean(true)).toBe(true); + expect(isBoolean(false)).toBe(true); + expect(isBoolean(1)).toBe(false); + }); + + it("should check arrays", () => { + expect(isArray([])).toBe(true); + expect(isArray([1, 2, 3])).toBe(true); + expect(isArray({})).toBe(false); + }); + + it("should check objects", () => { + expect(isObject({})).toBe(true); + expect(isObject({ a: 1 })).toBe(true); + expect(isObject([])).toBe(false); + expect(isObject(null)).toBe(false); + }); + + it("should check functions", () => { + expect(isFunction(() => { })).toBe(true); + expect(isFunction(function () { })).toBe(true); + expect(isFunction("function")).toBe(false); + }); + + it("should check dates", () => { + expect(isDate(new Date())).toBe(true); + expect(isDate(new Date("invalid"))).toBe(false); + expect(isDate("2023-01-01")).toBe(false); + }); + }); + + describe("isValidRegex", () => { + it("should validate correct regex patterns", () => { + expect(isValidRegex("[a-z]+")).toBe(true); + expect(isValidRegex("\\d{3}")).toBe(true); + expect(isValidRegex(".*")).toBe(true); + }); + + it("should reject invalid regex patterns", () => { + expect(isValidRegex("[")).toBe(false); + expect(isValidRegex("*")).toBe(false); + expect(isValidRegex("(?")).toBe(false); + }); + }); +}); diff --git a/helpers/type/typeChecks.ts b/helpers/type/typeChecks.ts new file mode 100644 index 00000000..0f618513 --- /dev/null +++ b/helpers/type/typeChecks.ts @@ -0,0 +1,96 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Type for values that can be T, undefined, or null + */ +export type Maybe = T | undefined | null; + +/** + * Checks if a value is set (not undefined nor null) + * @param value - The value to check + * @returns True if value is not undefined nor null + */ +export function isSet(value: Maybe): value is T { + return value !== undefined && value !== null; +} + +/** + * Checks if a value is a string + * @param value - The value to check + * @returns True if value is a string + */ +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +/** + * Checks if a value is a number + * @param value - The value to check + * @returns True if value is a number + */ +export function isNumber(value: unknown): value is number { + return typeof value === 'number' && !isNaN(value); +} + +/** + * Checks if a value is a boolean + * @param value - The value to check + * @returns True if value is a boolean + */ +export function isBoolean(value: unknown): value is boolean { + return typeof value === 'boolean'; +} + +/** + * Checks if a value is an array + * @param value - The value to check + * @returns True if value is an array + */ +export function isArray(value: unknown): value is unknown[] { + return Array.isArray(value); +} + +/** + * Checks if a value is a plain object + * @param value - The value to check + * @returns True if value is a plain object + */ +export function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Checks if a value is a function + * @param value - The value to check + * @returns True if value is a function + */ +export function isFunction(value: unknown): value is Function { + return typeof value === 'function'; +} + +/** + * Checks if a value is a Date + * @param value - The value to check + * @returns True if value is a Date + */ +export function isDate(value: unknown): value is Date { + return value instanceof Date && !isNaN(value.getTime()); +} + +/** + * Checks if a string is a valid regex + * @param value - The string to check + * @returns True if the string is a valid regex pattern + */ +export function isValidRegex(value: string): boolean { + try { + new RegExp(value); + return true; + } catch { + return false; + } +} diff --git a/helpers/version/compare.test.ts b/helpers/version/compare.test.ts new file mode 100644 index 00000000..ea12f8a1 --- /dev/null +++ b/helpers/version/compare.test.ts @@ -0,0 +1,38 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { compare } from "./compare"; + +describe("compare", () => { + it("should return 0 for equal versions", () => { + expect(compare("1.0.0", "1.0.0")).toBe(0); + expect(compare("v1.0.0", "1.0.0")).toBe(0); + }); + + it("should return -1 when first version is lower", () => { + expect(compare("1.0.0", "1.0.1")).toBe(-1); + expect(compare("1.0.0", "1.1.0")).toBe(-1); + expect(compare("1.0.0", "2.0.0")).toBe(-1); + }); + + it("should return 1 when first version is higher", () => { + expect(compare("1.0.1", "1.0.0")).toBe(1); + expect(compare("1.1.0", "1.0.0")).toBe(1); + expect(compare("2.0.0", "1.0.0")).toBe(1); + }); + + it("should handle different lengths", () => { + expect(compare("1.0", "1.0.0")).toBe(0); + expect(compare("1.0.1", "1.0")).toBe(1); + expect(compare("1.0", "1.0.1")).toBe(-1); + }); + + it("should handle v prefix", () => { + expect(compare("v1.0.0", "v1.0.1")).toBe(-1); + expect(compare("v2.0.0", "v1.0.0")).toBe(1); + }); +}); diff --git a/helpers/version/compare.ts b/helpers/version/compare.ts new file mode 100644 index 00000000..fbe29955 --- /dev/null +++ b/helpers/version/compare.ts @@ -0,0 +1,32 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Compares two semantic version strings + * @param version1 - First version string + * @param version2 - Second version string + * @returns -1 if version1 < version2, 0 if equal, 1 if version1 > version2 + */ +export function compare(version1: string, version2: string): number { + const normalize = (v: string) => v.replace(/^v/, ''); + const v1 = normalize(version1); + const v2 = normalize(version2); + + const parts1 = v1.split('.').map(Number); + const parts2 = v2.split('.').map(Number); + + const maxLength = Math.max(parts1.length, parts2.length); + + for (let i = 0; i < maxLength; i++) { + const part1 = parts1[i] || 0; + const part2 = parts2[i] || 0; + + if (part1 < part2) return -1; + if (part1 > part2) return 1; + } + + return 0; +} diff --git a/helpers/version/increment.test.ts b/helpers/version/increment.test.ts new file mode 100644 index 00000000..6e07c443 --- /dev/null +++ b/helpers/version/increment.test.ts @@ -0,0 +1,34 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { increment } from "./increment"; + +describe("increment", () => { + it("should increment patch version", () => { + expect(increment("1.2.3", "patch")).toBe("1.2.4"); + expect(increment("v1.2.3", "patch")).toBe("v1.2.4"); + }); + + it("should increment minor version and reset patch", () => { + expect(increment("1.2.3", "minor")).toBe("1.3.0"); + expect(increment("v1.2.3", "minor")).toBe("v1.3.0"); + }); + + it("should increment major version and reset minor and patch", () => { + expect(increment("1.2.3", "major")).toBe("2.0.0"); + expect(increment("v1.2.3", "major")).toBe("v2.0.0"); + }); + + it("should handle incomplete versions", () => { + expect(increment("1.2", "patch")).toBe("1.2.1"); + expect(increment("1", "minor")).toBe("1.1.0"); + }); + + it("should throw for invalid increment type", () => { + expect(() => increment("1.0.0", "invalid" as any)).toThrow(); + }); +}); diff --git a/helpers/version/increment.ts b/helpers/version/increment.ts new file mode 100644 index 00000000..1d5aceee --- /dev/null +++ b/helpers/version/increment.ts @@ -0,0 +1,49 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Increments a semantic version + * @param version - The version to increment + * @param type - The increment type ('major', 'minor', 'patch') + * @returns Incremented version string + */ +export function increment( + version: string, + type: 'major' | 'minor' | 'patch' +): string { + const normalize = (v: string) => v.replace(/^v/, ''); + const hasV = version.startsWith('v'); + const normalizedVersion = normalize(version); + + const parts = normalizedVersion.split('.').map(Number); + + // Ensure we have at least major.minor.patch + while (parts.length < 3) { + parts.push(0); + } + + let [major, minor, patch] = parts; + + switch (type) { + case 'major': + major++; + minor = 0; + patch = 0; + break; + case 'minor': + minor++; + patch = 0; + break; + case 'patch': + patch++; + break; + default: + throw new Error(`Invalid increment type: ${type}`); + } + + const result = `${major}.${minor}.${patch}`; + return hasV ? `v${result}` : result; +} diff --git a/helpers/version/satisfiesRange.test.ts b/helpers/version/satisfiesRange.test.ts new file mode 100644 index 00000000..11665677 --- /dev/null +++ b/helpers/version/satisfiesRange.test.ts @@ -0,0 +1,52 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from "bun:test"; +import { satisfiesRange } from "./satisfiesRange"; + +describe("satisfiesRange", () => { + it("should handle exact matches", () => { + expect(satisfiesRange("1.0.0", "1.0.0")).toBe(true); + expect(satisfiesRange("1.0.0", "1.0.1")).toBe(false); + expect(satisfiesRange("v1.0.0", "1.0.0")).toBe(true); + }); + + it("should handle >= operator", () => { + expect(satisfiesRange("1.0.1", ">=1.0.0")).toBe(true); + expect(satisfiesRange("1.0.0", ">=1.0.0")).toBe(true); + expect(satisfiesRange("0.9.9", ">=1.0.0")).toBe(false); + }); + + it("should handle > operator", () => { + expect(satisfiesRange("1.0.1", ">1.0.0")).toBe(true); + expect(satisfiesRange("1.0.0", ">1.0.0")).toBe(false); + }); + + it("should handle <= operator", () => { + expect(satisfiesRange("0.9.9", "<=1.0.0")).toBe(true); + expect(satisfiesRange("1.0.0", "<=1.0.0")).toBe(true); + expect(satisfiesRange("1.0.1", "<=1.0.0")).toBe(false); + }); + + it("should handle < operator", () => { + expect(satisfiesRange("0.9.9", "<1.0.0")).toBe(true); + expect(satisfiesRange("1.0.0", "<1.0.0")).toBe(false); + }); + + it("should handle caret ranges", () => { + expect(satisfiesRange("1.2.3", "^1.0.0")).toBe(true); + expect(satisfiesRange("1.0.0", "^1.0.0")).toBe(true); + expect(satisfiesRange("2.0.0", "^1.0.0")).toBe(false); + expect(satisfiesRange("0.9.9", "^1.0.0")).toBe(false); + }); + + it("should handle tilde ranges", () => { + expect(satisfiesRange("1.2.4", "~1.2.0")).toBe(true); + expect(satisfiesRange("1.2.0", "~1.2.0")).toBe(true); + expect(satisfiesRange("1.3.0", "~1.2.0")).toBe(false); + expect(satisfiesRange("1.1.9", "~1.2.0")).toBe(false); + }); +}); diff --git a/helpers/version/satisfiesRange.ts b/helpers/version/satisfiesRange.ts new file mode 100644 index 00000000..44b727f2 --- /dev/null +++ b/helpers/version/satisfiesRange.ts @@ -0,0 +1,85 @@ +/** + * This file is part of helpers4. + * Copyright (C) 2025 baxyz + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Checks if a version satisfies a range (simple implementation) + * @param version - Version to check + * @param range - Range pattern (e.g., ">=1.0.0", "~1.2.0", "^1.0.0") + * @returns True if version satisfies the range + */ +export function satisfiesRange(version: string, range: string): boolean { + const normalize = (v: string) => v.replace(/^v/, ''); + const normalizedVersion = normalize(version); + + // Handle exact match + if (!range.match(/[~^<>=]/)) { + return normalizedVersion === normalize(range); + } + + // Handle >= operator + if (range.startsWith('>=')) { + const targetVersion = normalize(range.slice(2)); + return compareVersionsSimple(normalizedVersion, targetVersion) >= 0; + } + + // Handle > operator + if (range.startsWith('>')) { + const targetVersion = normalize(range.slice(1)); + return compareVersionsSimple(normalizedVersion, targetVersion) > 0; + } + + // Handle <= operator + if (range.startsWith('<=')) { + const targetVersion = normalize(range.slice(2)); + return compareVersionsSimple(normalizedVersion, targetVersion) <= 0; + } + + // Handle < operator + if (range.startsWith('<')) { + const targetVersion = normalize(range.slice(1)); + return compareVersionsSimple(normalizedVersion, targetVersion) < 0; + } + + // Handle caret range (^1.2.3 allows patch and minor updates) + if (range.startsWith('^')) { + const targetVersion = normalize(range.slice(1)); + const [targetMajor] = targetVersion.split('.').map(Number); + const [versionMajor] = normalizedVersion.split('.').map(Number); + + return versionMajor === targetMajor && + compareVersionsSimple(normalizedVersion, targetVersion) >= 0; + } + + // Handle tilde range (~1.2.3 allows patch updates) + if (range.startsWith('~')) { + const targetVersion = normalize(range.slice(1)); + const [targetMajor, targetMinor] = targetVersion.split('.').map(Number); + const [versionMajor, versionMinor] = normalizedVersion.split('.').map(Number); + + return versionMajor === targetMajor && + versionMinor === targetMinor && + compareVersionsSimple(normalizedVersion, targetVersion) >= 0; + } + + return false; +} + +function compareVersionsSimple(version1: string, version2: string): number { + const parts1 = version1.split('.').map(Number); + const parts2 = version2.split('.').map(Number); + + const maxLength = Math.max(parts1.length, parts2.length); + + for (let i = 0; i < maxLength; i++) { + const part1 = parts1[i] || 0; + const part2 = parts2[i] || 0; + + if (part1 < part2) return -1; + if (part1 > part2) return 1; + } + + return 0; +}