├── .nvmrc ├── .gitignore ├── test ├── tsconfig.json ├── searchUtils.test.ts ├── functionUtils.test.ts ├── asyncUtils.test.ts ├── storageUtils.test.ts ├── arrayUtils.test.ts ├── sort.test.ts ├── colorUtils.test.ts ├── numberUtils.test.ts ├── stringUtils.test.ts ├── objectUtils.test.ts └── fileUtils.test.ts ├── tsconfig.esm.json ├── .npmignore ├── src ├── index.ts ├── searchUtils.ts ├── asyncUtils.ts ├── storageUtils.ts ├── functionUtils.ts ├── stringUtils.ts ├── sortUtils.ts ├── dateUtils │ ├── formatDate.md │ └── dateUtils.types.ts ├── validationUtils.ts ├── numberUtils.ts ├── urlUtils.ts ├── arrayUtils.ts ├── objectUtils.ts ├── fileUtils.ts ├── colorUtils.ts └── imageUtils.ts ├── jest.config.js ├── tsconfig.json ├── .github └── workflows │ └── publish.yml ├── package.json ├── scripts └── fix-esm-extensions.cjs └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | yarn.lock 4 | package-lock.json 5 | dist 6 | 7 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["jest", "node"], 5 | "noEmit": true 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "ES2020", 5 | "target": "ES2019", 6 | "outDir": "./dist/esm", 7 | "rootDir": "./src", 8 | "moduleResolution": "node", 9 | "declaration": false, 10 | "declarationMap": false, 11 | "sourceMap": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source files 2 | src/ 3 | test/ 4 | 5 | # Development files 6 | .git/ 7 | .github/ 8 | .gitignore 9 | .vscode/ 10 | .idea/ 11 | 12 | # Development dependencies 13 | node_modules/ 14 | *.log 15 | *.tgz 16 | 17 | # Configuration files 18 | tsconfig.json 19 | jest.config.js 20 | .eslintrc* 21 | .prettierrc* 22 | 23 | # Documentation 24 | README.md 25 | functions-catalog.md 26 | 27 | # Lock files 28 | package-lock.json 29 | yarn.lock 30 | 31 | # Build artifacts (keep only what's needed) 32 | *.tsbuildinfo 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./dateUtils/dateUtils"; 2 | export * from "./imageUtils"; 3 | export * from "./sortUtils"; 4 | export * from "./searchUtils"; 5 | export * from "./stringUtils"; 6 | export * from "./numberUtils"; 7 | export * from "./objectUtils"; 8 | export * from "./arrayUtils"; 9 | export * from "./validationUtils"; 10 | export * from "./urlUtils"; 11 | export * from "./colorUtils"; 12 | export * from "./fileUtils"; 13 | export * from "./functionUtils"; 14 | export * from "./asyncUtils"; 15 | export * from "./storageUtils"; 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "jest-environment-jsdom", 4 | roots: ["/test"], 5 | transform: { 6 | "^.+\\.tsx?$": "ts-jest", 7 | }, 8 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 9 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 10 | setupFilesAfterEnv: [], 11 | collectCoverageFrom: ["src/**/*.{ts,tsx}", "!src/**/*.d.ts"], 12 | coverageDirectory: "coverage", 13 | coverageReporters: ["text", "lcov", "html"], 14 | testPathIgnorePatterns: ["/node_modules/", "/dist/"], 15 | }; 16 | -------------------------------------------------------------------------------- /src/searchUtils.ts: -------------------------------------------------------------------------------- 1 | export const searchInArray = (array: T[], key: keyof T, value: string): T[] => { 2 | return array.filter((item) => item[key]?.toString().toLowerCase().includes(value.toLowerCase())); 3 | }; 4 | 5 | export const searchWithMultipleKeys = (array: T[], keys: (keyof T)[], value: string): T[] => { 6 | return array.filter((item) => keys.some((key) => item[key]?.toString().toLowerCase().includes(value.toLowerCase()))); 7 | }; 8 | 9 | export const searchWithCustomComparator = (array: T[], comparator: (item: T) => boolean): T[] => { 10 | return array.filter(comparator); 11 | }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "commonjs", 5 | "lib": ["ES2017", "DOM"], 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "outDir": "./dist", 10 | "rootDir": "./src", 11 | "removeComments": true, 12 | "strict": true, 13 | "noImplicitAny": true, 14 | "strictNullChecks": true, 15 | "strictFunctionTypes": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "moduleResolution": "node", 19 | "baseUrl": "./", 20 | "esModuleInterop": true, 21 | "allowSyntheticDefaultImports": true, 22 | "experimentalDecorators": true, 23 | "emitDecoratorMetadata": true, 24 | "skipLibCheck": true, 25 | "forceConsistentCasingInFileNames": true, 26 | "resolveJsonModule": true, 27 | "types": ["jest", "node"] 28 | }, 29 | "include": ["src/**/*"], 30 | "exclude": ["node_modules", "dist", "src/test/*"] 31 | } 32 | -------------------------------------------------------------------------------- /test/searchUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@jest/globals"; 2 | import { searchInArray, searchWithMultipleKeys, searchWithCustomComparator } from "../src/searchUtils"; 3 | 4 | test("searches in array of objects by key", () => { 5 | const array = [{ name: "Alice" }, { name: "Bob" }]; 6 | const results = searchInArray(array, "name", "bob"); 7 | expect(results).toEqual([{ name: "Bob" }]); 8 | }); 9 | 10 | test("searches in array of objects by multiple keys", () => { 11 | const array = [ 12 | { name: "Alice", city: "New York" }, 13 | { name: "Bob", city: "Los Angeles" }, 14 | ]; 15 | const results = searchWithMultipleKeys(array, ["name", "city"], "los"); 16 | expect(results).toEqual([{ name: "Bob", city: "Los Angeles" }]); 17 | }); 18 | 19 | test("searches in array of objects using a custom comparator", () => { 20 | const array = [{ name: "Alice" }, { name: "Bob" }]; 21 | const results = searchWithCustomComparator(array, (item) => item.name.startsWith("A")); 22 | expect(results).toEqual([{ name: "Alice" }]); 23 | }); 24 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: NPM - Complete JS Utils 2 | 3 | on: 4 | push: 5 | branches: ["main", "develop"] 6 | 7 | jobs: 8 | unit-tests: 9 | name: Unit-Tests 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 60 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 22 18 | cache: "npm" 19 | - run: npm install --global yarn 20 | - run: yarn 21 | 22 | - name: Unit Tests (using Jest) 23 | run: yarn test 24 | 25 | build: 26 | runs-on: ubuntu-latest 27 | if: "contains(github.event.head_commit.message, 'deploy')" 28 | strategy: 29 | matrix: 30 | node-version: [22] 31 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 32 | 33 | steps: 34 | - uses: actions/checkout@v3 35 | - name: Use Node.js ${{ matrix.node-version }} 36 | uses: actions/setup-node@v3 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | cache: "npm" 40 | - run: npm install --global yarn 41 | - run: yarn 42 | - run: yarn install 43 | - run: yarn build 44 | # - run: yarn test 45 | 46 | publish-npm: 47 | needs: build 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v3 51 | - uses: actions/setup-node@v3 52 | with: 53 | node-version: 22 54 | registry-url: https://registry.npmjs.org/ 55 | - run: npm install --global yarn 56 | - run: yarn 57 | - run: yarn install 58 | - run: yarn publish 59 | env: 60 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "complete-js-utils", 3 | "version": "1.1.2", 4 | "description": "A complete utility library for JavaScript and TypeScript", 5 | "main": "dist/index.js", 6 | "module": "dist/esm/index.mjs", 7 | "types": "dist/index.d.ts", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/index.d.ts", 11 | "require": "./dist/index.js", 12 | "import": "./dist/esm/index.mjs" 13 | } 14 | }, 15 | "sideEffects": false, 16 | "files": [ 17 | "dist" 18 | ], 19 | "scripts": { 20 | "build": "run-s build:cjs build:esm postbuild:esm", 21 | "build:cjs": "tsc -p tsconfig.json", 22 | "build:esm": "tsc -p tsconfig.esm.json", 23 | "postbuild:esm": "node scripts/fix-esm-extensions.cjs", 24 | "clean": "rm -rf dist", 25 | "prepublishOnly": "npm run clean && npm run build", 26 | "test": "jest", 27 | "update-version": "npm version prerelease --preid=alpha --no-git-tag-version", 28 | "update-version-patch": "npm version patch --no-git-tag-version", 29 | "update-version-minor": "npm version minor --no-git-tag-version", 30 | "update-version-major": "npm version major --no-git-tag-version" 31 | }, 32 | "keywords": [ 33 | "javascript", 34 | "typescript", 35 | "utilities", 36 | "utils", 37 | "library", 38 | "helpers", 39 | "date", 40 | "string", 41 | "array", 42 | "object", 43 | "validation" 44 | ], 45 | "repository": { 46 | "type": "git", 47 | "url": "https://github.com/nachorsanz/complete-js-utils.git" 48 | }, 49 | "author": "Nacho Rodríguez Sanz", 50 | "license": "MIT", 51 | "devDependencies": { 52 | "@types/jest": "^30.0.0", 53 | "jest": "^30.0.5", 54 | "jest-environment-jsdom": "^30.0.5", 55 | "jsdom": "^26.1.0", 56 | "npm-run-all": "^4.1.5", 57 | "ts-jest": "^29.4.1", 58 | "typescript": "^5.9.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /scripts/fix-esm-extensions.cjs: -------------------------------------------------------------------------------- 1 | // Post-build fix for ESM: rename ESM JS files to .mjs and rewrite relative imports/exports 2 | // This avoids Node's MODULE_TYPELESS_PACKAGE_JSON warning without setting "type":"module". 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | 6 | const esmDir = path.join(__dirname, "..", "dist", "esm"); 7 | 8 | function walk(dir) { 9 | return fs.readdirSync(dir, { withFileTypes: true }).flatMap((d) => { 10 | const p = path.join(dir, d.name); 11 | if (d.isDirectory()) return walk(p); 12 | return [p]; 13 | }); 14 | } 15 | 16 | function rewriteRelativeSpecifiers(code) { 17 | // Replace import/export specifiers for relative paths to use .mjs 18 | // Matches: import ... from "./x" | "./x.js" and export ... from "./x" 19 | return code 20 | .replace(/((?:import|export)\s[^;]*?from\s+["'])(\.\.\/.+?|\.\/.+?)(["'])/g, (m, p1, spec, p3) => { 21 | if (!spec.startsWith("./") && !spec.startsWith("../")) return m; 22 | if (spec.endsWith(".mjs")) return m; 23 | if (spec.endsWith(".js")) return p1 + spec.slice(0, -3) + ".mjs" + p3; 24 | return p1 + spec + ".mjs" + p3; 25 | }) 26 | .replace(/(import\s*\(\s*["'])(\.\.\/.+?|\.\/.+?)(["']\s*\))/g, (m, p1, spec, p3) => { 27 | if (!spec.startsWith("./") && !spec.startsWith("../")) return m; 28 | if (spec.endsWith(".mjs")) return m; 29 | if (spec.endsWith(".js")) return p1 + spec.slice(0, -3) + ".mjs" + p3; 30 | return p1 + spec + ".mjs" + p3; 31 | }); 32 | } 33 | 34 | try { 35 | const files = walk(esmDir).filter((f) => f.endsWith(".js")); 36 | // First, rewrite contents to use .mjs specifiers 37 | for (const file of files) { 38 | const code = fs.readFileSync(file, "utf8"); 39 | const out = rewriteRelativeSpecifiers(code); 40 | if (out !== code) fs.writeFileSync(file, out, "utf8"); 41 | } 42 | // Then, rename .js -> .mjs 43 | for (const file of files.sort((a, b) => b.length - a.length)) { 44 | const target = file.slice(0, -3) + ".mjs"; 45 | fs.renameSync(file, target); 46 | } 47 | console.log("ESM: renamed .js to .mjs and fixed relative specifiers"); 48 | } catch (e) { 49 | console.error("fix-esm-extensions failed:", e.stack || e.message); 50 | process.exit(1); 51 | } 52 | -------------------------------------------------------------------------------- /src/asyncUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Async utilities: retry, withTimeout, delay, pLimit, parallelMap 3 | */ 4 | 5 | export const delay = (ms: number): Promise => new Promise((res) => setTimeout(res, ms)); 6 | 7 | export type RetryOptions = { 8 | retries?: number; 9 | factor?: number; // exponential backoff factor 10 | minTimeout?: number; // initial delay 11 | maxTimeout?: number; // cap delay 12 | onRetry?: (attempt: number, error: unknown) => void; 13 | }; 14 | 15 | export const retry = async (fn: () => Promise, options: RetryOptions = {}): Promise => { 16 | const { retries = 3, factor = 2, minTimeout = 100, maxTimeout = 2000, onRetry } = options; 17 | let attempt = 0; 18 | let delayMs = minTimeout; 19 | 20 | while (true) { 21 | try { 22 | return await fn(); 23 | } catch (error) { 24 | if (attempt >= retries) throw error; 25 | onRetry?.(attempt + 1, error); 26 | await delay(delayMs); 27 | delayMs = Math.min(maxTimeout, Math.round(delayMs * factor)); 28 | attempt++; 29 | } 30 | } 31 | }; 32 | 33 | export const withTimeout = async (promise: Promise, ms: number, message = "Timeout exceeded"): Promise => { 34 | let timeoutId: ReturnType; 35 | const timeoutPromise = new Promise((_, reject) => { 36 | timeoutId = setTimeout(() => reject(new Error(message)), ms); 37 | }); 38 | try { 39 | return await Promise.race([promise, timeoutPromise]); 40 | } finally { 41 | clearTimeout(timeoutId!); 42 | } 43 | }; 44 | 45 | export const pLimit = (concurrency: number) => { 46 | if (concurrency < 1) throw new Error("Concurrency must be at least 1"); 47 | let activeCount = 0; 48 | const queue: Array<() => void> = []; 49 | 50 | const next = () => { 51 | activeCount--; 52 | if (queue.length > 0) { 53 | const run = queue.shift(); 54 | run && run(); 55 | } 56 | }; 57 | 58 | const run = async (fn: () => Promise): Promise => { 59 | if (activeCount >= concurrency) { 60 | await new Promise((resolve) => queue.push(resolve)); 61 | } 62 | activeCount++; 63 | try { 64 | const result = await fn(); 65 | return result; 66 | } finally { 67 | next(); 68 | } 69 | }; 70 | 71 | return run; 72 | }; 73 | 74 | export const parallelMap = async ( 75 | items: T[], 76 | mapper: (item: T, index: number) => Promise, 77 | concurrency = 5, 78 | ): Promise => { 79 | const limit = pLimit(concurrency); 80 | return Promise.all(items.map((item, i) => limit(() => mapper(item, i)))); 81 | }; 82 | -------------------------------------------------------------------------------- /src/storageUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Storage utilities: safe wrappers around localStorage/sessionStorage + in-memory fallback 3 | */ 4 | 5 | export type StorageLike = { 6 | getItem(key: string): string | null; 7 | setItem(key: string, value: string): void; 8 | removeItem(key: string): void; 9 | clear(): void; 10 | }; 11 | 12 | const hasWindow = typeof window !== "undefined" && typeof window.localStorage !== "undefined"; 13 | 14 | export class MemoryStorage implements StorageLike { 15 | private store = new Map(); 16 | getItem(key: string): string | null { 17 | return this.store.has(key) ? this.store.get(key)! : null; 18 | } 19 | setItem(key: string, value: string): void { 20 | this.store.set(key, value); 21 | } 22 | removeItem(key: string): void { 23 | this.store.delete(key); 24 | } 25 | clear(): void { 26 | this.store.clear(); 27 | } 28 | } 29 | 30 | export const createSafeStorage = (storage: StorageLike | null): StorageLike => { 31 | if (!storage) return new MemoryStorage(); 32 | try { 33 | const testKey = "__test__"; 34 | storage.setItem(testKey, "1"); 35 | storage.removeItem(testKey); 36 | return storage; 37 | } catch { 38 | return new MemoryStorage(); 39 | } 40 | }; 41 | 42 | export const safeLocalStorage: StorageLike = createSafeStorage(hasWindow ? window.localStorage : null); 43 | export const safeSessionStorage: StorageLike = createSafeStorage(hasWindow ? window.sessionStorage : null); 44 | 45 | export const setJSON = (storage: StorageLike, key: string, value: unknown): void => { 46 | storage.setItem(key, JSON.stringify(value)); 47 | }; 48 | 49 | export const getJSON = (storage: StorageLike, key: string, fallback: T): T => { 50 | const raw = storage.getItem(key); 51 | if (!raw) return fallback; 52 | try { 53 | return JSON.parse(raw) as T; 54 | } catch { 55 | return fallback; 56 | } 57 | }; 58 | 59 | export const remove = (storage: StorageLike, key: string): void => storage.removeItem(key); 60 | export const clear = (storage: StorageLike): void => storage.clear(); 61 | 62 | export const namespacedStorage = (storage: StorageLike, namespace: string) => { 63 | const prefix = `${namespace}::`; 64 | const withNs = (key: string) => `${prefix}${key}`; 65 | return { 66 | set: (key: string, value: string) => storage.setItem(withNs(key), value), 67 | get: (key: string) => storage.getItem(withNs(key)), 68 | setJSON: (key: string, value: unknown) => setJSON(storage, withNs(key), value), 69 | getJSON: (key: string, fallback: T) => getJSON(storage, withNs(key), fallback), 70 | remove: (key: string) => storage.removeItem(withNs(key)), 71 | }; 72 | }; 73 | -------------------------------------------------------------------------------- /test/functionUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { debounce, throttle, once, memoize, compose, pipe } from "../src/functionUtils"; 2 | import { jest, describe, test, expect } from "@jest/globals"; 3 | 4 | jest.useFakeTimers(); 5 | 6 | describe("FunctionUtils", () => { 7 | test("debounce basic trailing", () => { 8 | const fn = jest.fn(); 9 | const d = debounce(fn, 100); 10 | d(); 11 | d(); 12 | expect(fn).not.toHaveBeenCalled(); 13 | jest.advanceTimersByTime(100); 14 | expect(fn).toHaveBeenCalledTimes(1); 15 | }); 16 | 17 | test("debounce leading", () => { 18 | const fn = jest.fn(); 19 | const d = debounce(fn, 100, { leading: true, trailing: false }); 20 | d(); 21 | expect(fn).toHaveBeenCalledTimes(1); 22 | jest.advanceTimersByTime(100); 23 | d(); 24 | expect(fn).toHaveBeenCalledTimes(2); 25 | }); 26 | 27 | test("debounce cancel and flush", () => { 28 | const fn = jest.fn(); 29 | const d = debounce(fn, 100); 30 | d("a"); 31 | // cancel should prevent trailing call 32 | d.cancel(); 33 | jest.advanceTimersByTime(200); 34 | expect(fn).not.toHaveBeenCalled(); 35 | 36 | // try again and flush immediately 37 | d("b"); 38 | d.flush(); 39 | expect(fn).toHaveBeenCalledTimes(1); 40 | }); 41 | 42 | test("throttle", () => { 43 | const fn = jest.fn(); 44 | const t = throttle(fn, 100); 45 | t(); 46 | t(); 47 | expect(fn).toHaveBeenCalledTimes(1); 48 | jest.advanceTimersByTime(100); 49 | expect(fn).toHaveBeenCalledTimes(2); 50 | }); 51 | 52 | test("throttle cancel", () => { 53 | const fn = jest.fn(); 54 | const t = throttle(fn, 100); 55 | t(); // call immediately 56 | // schedule a trailing call 57 | t(); 58 | // cancel should prevent the scheduled call 59 | t.cancel(); 60 | jest.advanceTimersByTime(200); 61 | expect(fn).toHaveBeenCalledTimes(1); 62 | }); 63 | 64 | test("once", () => { 65 | const fn = jest.fn((x: number) => x * 2); 66 | const o = once(fn); 67 | expect(o(2)).toBe(4); 68 | expect(o(3)).toBe(4); 69 | expect(fn).toHaveBeenCalledTimes(1); 70 | }); 71 | 72 | test("memoize", () => { 73 | const fn = jest.fn((x: number) => x * 2); 74 | const m = memoize(fn); 75 | expect(m(2)).toBe(4); 76 | expect(m(2)).toBe(4); 77 | expect(fn).toHaveBeenCalledTimes(1); 78 | m.clear(); 79 | expect(m(2)).toBe(4); 80 | expect(fn).toHaveBeenCalledTimes(2); 81 | }); 82 | 83 | test("compose and pipe", () => { 84 | const add1 = (x: number) => x + 1; 85 | const double = (x: number) => x * 2; 86 | const c = compose(double, add1); 87 | const p = pipe(add1, double); 88 | expect(c(3)).toBe(8); // double(add1(3)) = 8 89 | expect(p(3)).toBe(8); // double(add1(3)) = 8 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/asyncUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { delay, retry, withTimeout, pLimit, parallelMap } from "../src/asyncUtils"; 2 | import { describe, test, expect } from "@jest/globals"; 3 | 4 | describe("AsyncUtils", () => { 5 | test("delay waits the specified time", async () => { 6 | const start = Date.now(); 7 | await delay(50); 8 | const elapsed = Date.now() - start; 9 | expect(elapsed).toBeGreaterThanOrEqual(45); 10 | }); 11 | 12 | test("retry succeeds after transient failures", async () => { 13 | let attempts = 0; 14 | const fn = async () => { 15 | attempts++; 16 | if (attempts < 3) throw new Error("fail"); 17 | return "ok"; 18 | }; 19 | const result = await retry(fn, { retries: 5, minTimeout: 10, factor: 1 }); 20 | expect(result).toBe("ok"); 21 | expect(attempts).toBe(3); 22 | }); 23 | 24 | test("retry throws after max retries", async () => { 25 | let attempts = 0; 26 | const fn = async () => { 27 | attempts++; 28 | throw new Error("always fail"); 29 | }; 30 | await expect(retry(fn, { retries: 2, minTimeout: 5, factor: 1 })).rejects.toThrow("always fail"); 31 | expect(attempts).toBe(3); // initial + 2 retries 32 | }); 33 | 34 | test("retry calls onRetry callback", async () => { 35 | const calls: Array<{ attempt: number; msg: string }> = []; 36 | let attempts = 0; 37 | const fn = async () => { 38 | attempts++; 39 | if (attempts < 2) throw new Error("fail-once"); 40 | return "ok"; 41 | }; 42 | const result = await retry(fn, { 43 | retries: 3, 44 | minTimeout: 5, 45 | factor: 1, 46 | onRetry: (attempt, err) => calls.push({ attempt, msg: (err as Error).message }), 47 | }); 48 | expect(result).toBe("ok"); 49 | expect(calls).toEqual([{ attempt: 1, msg: "fail-once" }]); 50 | }); 51 | 52 | test("pLimit throws when concurrency < 1", () => { 53 | expect(() => pLimit(0)).toThrow(/Concurrency must be at least 1/); 54 | }); 55 | 56 | test("withTimeout resolves before timeout", async () => { 57 | const result = await withTimeout(Promise.resolve(42), 100); 58 | expect(result).toBe(42); 59 | }); 60 | 61 | test("withTimeout rejects on timeout", async () => { 62 | await expect( 63 | withTimeout( 64 | delay(50).then(() => 1 as unknown as Promise), 65 | 10, 66 | ), 67 | ).rejects.toThrow(/Timeout exceeded/); 68 | }); 69 | 70 | test("pLimit enforces concurrency", async () => { 71 | const limit = pLimit(2); 72 | let running = 0; 73 | let maxRunning = 0; 74 | 75 | const task = async (ms: number) => { 76 | running++; 77 | maxRunning = Math.max(maxRunning, running); 78 | await delay(ms); 79 | running--; 80 | return ms; 81 | }; 82 | 83 | const results = await Promise.all([ 84 | limit(() => task(30)), 85 | limit(() => task(30)), 86 | limit(() => task(30)), 87 | limit(() => task(30)), 88 | ]); 89 | 90 | expect(results).toHaveLength(4); 91 | expect(maxRunning).toBeLessThanOrEqual(2); 92 | }); 93 | 94 | test("parallelMap maps with limited concurrency", async () => { 95 | const items = [1, 2, 3, 4, 5]; 96 | const results = await parallelMap( 97 | items, 98 | async (n) => { 99 | await delay(10); 100 | return n * 2; 101 | }, 102 | 2, 103 | ); 104 | expect(results).toEqual([2, 4, 6, 8, 10]); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /test/storageUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | safeLocalStorage, 3 | safeSessionStorage, 4 | setJSON, 5 | getJSON, 6 | remove, 7 | clear, 8 | namespacedStorage, 9 | MemoryStorage, 10 | } from "../src/storageUtils"; 11 | import { describe, test, expect } from "@jest/globals"; 12 | 13 | describe("StorageUtils", () => { 14 | test("safe storages are usable", () => { 15 | safeLocalStorage.setItem("k", "v"); 16 | expect(safeLocalStorage.getItem("k")).toBe("v"); 17 | safeLocalStorage.removeItem("k"); 18 | expect(safeLocalStorage.getItem("k")).toBeNull(); 19 | 20 | safeSessionStorage.setItem("k", "v"); 21 | expect(safeSessionStorage.getItem("k")).toBe("v"); 22 | }); 23 | 24 | test("JSON helpers", () => { 25 | setJSON(safeLocalStorage, "obj", { a: 1 }); 26 | expect(getJSON(safeLocalStorage, "obj", { a: 0 })).toEqual({ a: 1 }); 27 | expect(getJSON(safeLocalStorage, "missing", { b: 2 })).toEqual({ b: 2 }); 28 | // invalid JSON should fallback 29 | safeLocalStorage.setItem("bad", "{invalid}"); 30 | expect(getJSON(safeLocalStorage, "bad", { ok: false })).toEqual({ ok: false }); 31 | }); 32 | 33 | test("remove and clear", () => { 34 | safeLocalStorage.setItem("a", "1"); 35 | remove(safeLocalStorage, "a"); 36 | expect(safeLocalStorage.getItem("a")).toBeNull(); 37 | 38 | safeLocalStorage.setItem("a", "1"); 39 | safeLocalStorage.setItem("b", "2"); 40 | clear(safeLocalStorage); 41 | expect(safeLocalStorage.getItem("a")).toBeNull(); 42 | expect(safeLocalStorage.getItem("b")).toBeNull(); 43 | }); 44 | 45 | test("namespaced storage", () => { 46 | const ns = namespacedStorage(safeLocalStorage, "app"); 47 | ns.set("x", "1"); 48 | expect(ns.get("x")).toBe("1"); 49 | ns.setJSON("y", { c: 3 }); 50 | expect(ns.getJSON("y", { c: 0 })).toEqual({ c: 3 }); 51 | ns.remove("x"); 52 | expect(ns.get("x")).toBeNull(); 53 | }); 54 | 55 | test("session storage works similarly", () => { 56 | safeSessionStorage.setItem("s", "1"); 57 | expect(safeSessionStorage.getItem("s")).toBe("1"); 58 | setJSON(safeSessionStorage, "j", { k: 2 }); 59 | expect(getJSON(safeSessionStorage, "j", { k: 0 })).toEqual({ k: 2 }); 60 | clear(safeSessionStorage); 61 | expect(safeSessionStorage.getItem("s")).toBeNull(); 62 | }); 63 | 64 | test("falls back to in-memory storage if native storage fails", () => { 65 | const originalLS = (global as any).window?.localStorage; 66 | (global as any).window.localStorage = { 67 | getItem() { 68 | return null; 69 | }, 70 | setItem() { 71 | throw new Error("fail"); 72 | }, 73 | removeItem() {}, 74 | clear() {}, 75 | } as any; 76 | 77 | jest.isolateModules(() => { 78 | // Re-import module to re-run createSafeStorage with failing localStorage 79 | const mod = require("../src/storageUtils"); 80 | const ls = mod.safeLocalStorage as Storage; 81 | // Should not throw and should store/retrieve values (in-memory fallback) 82 | ls.setItem("t", "1"); 83 | expect(ls.getItem("t")).toBe("1"); 84 | ls.removeItem("t"); 85 | expect(ls.getItem("t")).toBeNull(); 86 | }); 87 | 88 | // restore 89 | (global as any).window.localStorage = originalLS; 90 | }); 91 | 92 | test("MemoryStorage basic operations cover all methods", () => { 93 | const mem = new MemoryStorage(); 94 | expect(mem.getItem("x")).toBeNull(); 95 | mem.setItem("x", "1"); 96 | expect(mem.getItem("x")).toBe("1"); 97 | mem.removeItem("x"); 98 | expect(mem.getItem("x")).toBeNull(); 99 | mem.setItem("a", "2"); 100 | mem.setItem("b", "3"); 101 | mem.clear(); 102 | expect(mem.getItem("a")).toBeNull(); 103 | expect(mem.getItem("b")).toBeNull(); 104 | }); 105 | 106 | test("getJSON catch branch on invalid JSON using custom storage", () => { 107 | const badStorage = { 108 | getItem: () => "{invalid}", 109 | setItem: () => {}, 110 | removeItem: () => {}, 111 | clear: () => {}, 112 | } as any; 113 | expect(getJSON(badStorage, "k", { ok: true })).toEqual({ ok: true }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # complete-js-utils 2 | 3 | Complete, zero-dependency utility library for JavaScript and TypeScript — hundreds of functions across 15 categories for dates, strings, arrays, objects, numbers, colors, files, images, search/sort, validation, and more. 4 | 5 | ## What’s new 6 | 7 | We’ve added three new categories to keep your toolbox modern and productive: 8 | 9 | - 🧠 Function Utils — debounce, throttle, once, memoize, compose, pipe 10 | - ⏳ Async Utils — delay, retry, withTimeout, pLimit, parallelMap 11 | - 💾 Storage Utils — safeLocalStorage, safeSessionStorage, JSON helpers, namespacedStorage 12 | 13 | These are fully typed and included in the main entry. In the docs site, you’ll see a “New” badge highlighting these additions. 14 | 15 | ## Installation 16 | 17 | Install the library via npm or yarn: 18 | 19 | ```bash 20 | npm install complete-js-utils 21 | # or 22 | yarn add complete-js-utils 23 | ``` 24 | 25 | ## Quick Start 26 | 27 | ### TypeScript / ES Modules 28 | 29 | ```ts 30 | import { formatDate, isEmailValid, sortArray, debounce } from 'complete-js-utils'; 31 | 32 | const date = formatDate(new Date(), 'yyyy-MM-dd'); 33 | const isValid = isEmailValid('test@example.com'); 34 | const sorted = sortArray( 35 | [ 36 | { name: 'John', age: 30 }, 37 | { name: 'Jane', age: 25 }, 38 | ], 39 | 'age', 40 | 'asc' 41 | ); 42 | 43 | const onScroll = debounce(() => console.log('scrolled'), 200); 44 | ``` 45 | 46 | ### JavaScript / CommonJS 47 | 48 | ```js 49 | const { formatDate, isEmailValid, sortArray, debounce } = require('complete-js-utils'); 50 | 51 | const date = formatDate(new Date(), 'yyyy-MM-dd'); 52 | const isValid = isEmailValid('test@example.com'); 53 | const sorted = sortArray( 54 | [ 55 | { name: 'John', age: 30 }, 56 | { name: 'Jane', age: 25 }, 57 | ], 58 | 'age' 59 | ); 60 | 61 | const onScroll = debounce(() => console.log('scrolled'), 200); 62 | ``` 63 | 64 | ## Features 65 | 66 | - ✅ 350+ utility functions across 15 categories 67 | - ✅ Full TypeScript support (d.ts included) 68 | - ✅ Tree‑shakeable (ESM) and CommonJS builds 69 | - ✅ Zero dependencies, lightweight and fast 70 | - ✅ Tiny footprint: ~13.9 kB gzipped 71 | - ✅ Well tested 72 | - ✅ Modern ES6+ 73 | 74 | ## Categories 75 | 76 | - 🔢 Array Utils — array manipulation and processing 77 | - 🎨 Color Utils — color format conversion and manipulation 78 | - 📅 Date Utils — date formatting and manipulation 79 | - 📁 File Utils — file operations and helpers 80 | - 🖼️ Image Utils — base64/Blob helpers, resize, grayscale, etc. 81 | - 🔢 Number Utils — math helpers and checks 82 | - 📦 Object Utils — object manipulation utilities 83 | - 🔍 Search Utils — search and filtering functions 84 | - 📊 Sort Utils — sorting helpers and small algorithms 85 | - 📝 String Utils — string processing and manipulation 86 | - 🌐 URL Utils — URL parsing and processing 87 | - ✅ Validation Utils — email, URL, UUID, IP, etc. 88 | - 🧠 Function Utils — debounce, throttle, once, memoize, compose, pipe 89 | - ⏳ Async Utils — delay, retry, withTimeout, pLimit, parallelMap 90 | - 💾 Storage Utils — safe storages, JSON helpers, namespaced storage 91 | 92 | ## TypeScript Support 93 | 94 | Written in TypeScript with first‑class types: 95 | 96 | - Complete type safety and inference 97 | - IntelliSense support 98 | - Generic utilities 99 | - Declaration files (.d.ts) 100 | - Source maps 101 | 102 | ## Documentation 103 | 104 | For full documentation with all functions and examples, visit: https://complete-js-utils.com 105 | 106 | ## Tree‑shaking 107 | 108 | This library is tree‑shakeable in modern bundlers (Vite, Rollup, Webpack production builds): 109 | 110 | - Dual builds: CommonJS (main) and ES Module (module). 111 | - Conditional exports: `package.json#exports` provides `require` and `import` fields. 112 | - Types: declaration files are published (`types` field). 113 | - Side‑effect free: `"sideEffects": false` enables dead‑code elimination. 114 | 115 | Import only what you need: 116 | 117 | ```ts 118 | // ESM 119 | import { formatDate, debounce } from 'complete-js-utils'; 120 | 121 | // CommonJS 122 | const { formatDate, debounce } = require('complete-js-utils'); 123 | ``` 124 | 125 | ## License 126 | 127 | MIT © Nacho Rodríguez Sanz 128 | -------------------------------------------------------------------------------- /src/functionUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Function utilities: debounce, throttle, once, memoize, compose, pipe 3 | */ 4 | 5 | export type AnyFunction = (...args: any[]) => any; 6 | 7 | export type DebounceOptions = { 8 | leading?: boolean; 9 | trailing?: boolean; 10 | }; 11 | 12 | export const debounce = ( 13 | fn: F, 14 | wait: number, 15 | options: DebounceOptions = {}, 16 | ): F & { 17 | cancel: () => void; 18 | flush: () => void; 19 | } => { 20 | const { leading = false, trailing = true } = options; 21 | let timeoutId: ReturnType | null = null; 22 | let lastArgs: Parameters | null = null; 23 | let lastThis: any; 24 | let leadingCalled = false; 25 | 26 | const invoke = () => { 27 | if (trailing && lastArgs) { 28 | fn.apply(lastThis, lastArgs); 29 | lastArgs = null; 30 | } 31 | timeoutId = null; 32 | leadingCalled = false; 33 | }; 34 | 35 | const debounced = function (this: any, ...args: Parameters) { 36 | lastArgs = args; 37 | lastThis = this; 38 | 39 | if (timeoutId == null) { 40 | if (leading && !leadingCalled) { 41 | fn.apply(lastThis, lastArgs); 42 | lastArgs = null; 43 | leadingCalled = true; 44 | } 45 | timeoutId = setTimeout(invoke, wait); 46 | } else { 47 | clearTimeout(timeoutId); 48 | timeoutId = setTimeout(invoke, wait); 49 | } 50 | } as unknown as F & { cancel: () => void; flush: () => void }; 51 | 52 | debounced.cancel = () => { 53 | if (timeoutId != null) { 54 | clearTimeout(timeoutId); 55 | timeoutId = null; 56 | } 57 | lastArgs = null; 58 | leadingCalled = false; 59 | }; 60 | 61 | debounced.flush = () => { 62 | if (timeoutId != null) { 63 | clearTimeout(timeoutId); 64 | invoke(); 65 | } 66 | }; 67 | 68 | return debounced; 69 | }; 70 | 71 | export const throttle = (fn: F, wait: number): F & { cancel: () => void } => { 72 | let lastCall = 0; 73 | let timeoutId: ReturnType | null = null; 74 | let lastArgs: Parameters | null = null; 75 | let lastThis: any; 76 | 77 | const invoke = () => { 78 | lastCall = Date.now(); 79 | if (lastArgs) { 80 | fn.apply(lastThis, lastArgs); 81 | lastArgs = null; 82 | } 83 | timeoutId = null; 84 | }; 85 | 86 | const throttled = function (this: any, ...args: Parameters) { 87 | const now = Date.now(); 88 | const remaining = wait - (now - lastCall); 89 | lastArgs = args; 90 | lastThis = this; 91 | 92 | if (remaining <= 0 || remaining > wait) { 93 | if (timeoutId) { 94 | clearTimeout(timeoutId); 95 | timeoutId = null; 96 | } 97 | invoke(); 98 | } else if (!timeoutId) { 99 | timeoutId = setTimeout(invoke, remaining); 100 | } 101 | } as unknown as F & { cancel: () => void }; 102 | 103 | throttled.cancel = () => { 104 | if (timeoutId) clearTimeout(timeoutId); 105 | timeoutId = null; 106 | lastArgs = null; 107 | }; 108 | 109 | return throttled; 110 | }; 111 | 112 | export const once = (fn: F): F => { 113 | let called = false; 114 | let result: ReturnType; 115 | return function (this: any, ...args: Parameters) { 116 | if (!called) { 117 | result = fn.apply(this, args); 118 | called = true; 119 | } 120 | return result; 121 | } as F; 122 | }; 123 | 124 | export const memoize = ( 125 | fn: F, 126 | resolver?: (...args: Parameters) => any, 127 | ): F & { 128 | cache: Map; 129 | clear: () => void; 130 | } => { 131 | const cache = new Map(); 132 | const memoized = function (this: any, ...args: Parameters) { 133 | const key = resolver ? resolver(...args) : args.length === 1 ? args[0] : JSON.stringify(args); 134 | if (cache.has(key)) return cache.get(key); 135 | const value = fn.apply(this, args); 136 | cache.set(key, value); 137 | return value; 138 | } as unknown as F & { cache: Map; clear: () => void }; 139 | memoized.cache = cache; 140 | memoized.clear = () => cache.clear(); 141 | return memoized; 142 | }; 143 | 144 | export const compose = 145 | (...fns: Array<(arg: any) => any>) => 146 | (input: T) => 147 | fns.reduceRight((acc, fn) => fn(acc), input); 148 | 149 | export const pipe = 150 | (...fns: Array<(arg: any) => any>) => 151 | (input: T) => 152 | fns.reduce((acc, fn) => fn(acc), input); 153 | -------------------------------------------------------------------------------- /src/stringUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * String utility functions 3 | */ 4 | 5 | export const capitalize = (str: string): string => { 6 | if (!str) return str; 7 | return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); 8 | }; 9 | 10 | export const camelCase = (str: string): string => { 11 | return str.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (match, chr) => chr.toUpperCase()); 12 | }; 13 | 14 | export const kebabCase = (str: string): string => { 15 | return str 16 | .replace(/([a-z])([A-Z])/g, "$1-$2") 17 | .replace(/[\s_]+/g, "-") 18 | .toLowerCase(); 19 | }; 20 | 21 | export const snakeCase = (str: string): string => { 22 | return str 23 | .replace(/([a-z])([A-Z])/g, "$1_$2") 24 | .replace(/[\s-]+/g, "_") 25 | .toLowerCase(); 26 | }; 27 | 28 | export const pascalCase = (str: string): string => { 29 | return str 30 | .replace(/([a-z])([A-Z])/g, "$1 $2") // Split camelCase 31 | .toLowerCase() 32 | .replace(/[^a-zA-Z0-9]+(.)/g, (match, chr) => chr.toUpperCase()) 33 | .replace(/^./, (chr) => chr.toUpperCase()); 34 | }; 35 | 36 | export const titleCase = (str: string): string => { 37 | return str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()); 38 | }; 39 | 40 | export const reverse = (str: string): string => { 41 | return str.split("").reverse().join(""); 42 | }; 43 | 44 | export const truncate = (str: string, length: number, suffix: string = "..."): string => { 45 | if (str.length <= length) return str; 46 | return str.slice(0, length) + suffix; 47 | }; 48 | 49 | export const padStart = (str: string, targetLength: number, padString: string = " "): string => { 50 | return str.padStart(targetLength, padString); 51 | }; 52 | 53 | export const padEnd = (str: string, targetLength: number, padString: string = " "): string => { 54 | return str.padEnd(targetLength, padString); 55 | }; 56 | 57 | export const removeAccents = (str: string): string => { 58 | return str.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); 59 | }; 60 | 61 | export const slugify = (str: string): string => { 62 | return removeAccents(str) 63 | .toLowerCase() 64 | .replace(/[^a-z0-9\s-]/g, "") 65 | .replace(/\s+/g, "-") 66 | .replace(/-+/g, "-") 67 | .trim(); 68 | }; 69 | 70 | export const extractNumbers = (str: string): number[] => { 71 | const matches = str.match(/\d+/g); 72 | return matches ? matches.map(Number) : []; 73 | }; 74 | 75 | export const countWords = (str: string): number => { 76 | return str 77 | .trim() 78 | .split(/\s+/) 79 | .filter((word) => word.length > 0).length; 80 | }; 81 | 82 | export const countCharacters = (str: string, includeSpaces: boolean = true): number => { 83 | return includeSpaces ? str.length : str.replace(/\s/g, "").length; 84 | }; 85 | 86 | export const isEmail = (str: string): boolean => { 87 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 88 | return emailRegex.test(str); 89 | }; 90 | 91 | export const isUrl = (str: string): boolean => { 92 | try { 93 | new URL(str); 94 | return true; 95 | } catch { 96 | return false; 97 | } 98 | }; 99 | 100 | export const maskString = ( 101 | str: string, 102 | maskChar: string = "*", 103 | visibleStart: number = 2, 104 | visibleEnd: number = 2, 105 | ): string => { 106 | if (str.length <= visibleStart + visibleEnd) return str; 107 | const start = str.slice(0, visibleStart); 108 | const end = str.slice(-visibleEnd); 109 | const maskLength = str.length - visibleStart - visibleEnd; 110 | return start + maskChar.repeat(maskLength) + end; 111 | }; 112 | 113 | export const randomString = ( 114 | length: number, 115 | chars: string = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 116 | ): string => { 117 | let result = ""; 118 | for (let i = 0; i < length; i++) { 119 | result += chars.charAt(Math.floor(Math.random() * chars.length)); 120 | } 121 | return result; 122 | }; 123 | 124 | export const escapeHtml = (str: string): string => { 125 | const div = document.createElement("div"); 126 | div.textContent = str; 127 | return div.innerHTML; 128 | }; 129 | 130 | export const unescapeHtml = (str: string): string => { 131 | const div = document.createElement("div"); 132 | div.innerHTML = str; 133 | return div.textContent || div.innerText || ""; 134 | }; 135 | 136 | export const stripHtml = (str: string): string => { 137 | return str.replace(/<[^>]*>/g, ""); 138 | }; 139 | 140 | export const highlightText = (text: string, searchTerm: string, highlightClass: string = "highlight"): string => { 141 | if (!searchTerm) return text; 142 | const regex = new RegExp(`(${searchTerm})`, "gi"); 143 | return text.replace(regex, `$1`); 144 | }; 145 | -------------------------------------------------------------------------------- /src/sortUtils.ts: -------------------------------------------------------------------------------- 1 | export const sortArray = (array: T[], key: keyof T, order: "asc" | "desc" = "asc"): T[] => { 2 | return [...array].sort((a, b) => { 3 | if (a[key] < b[key]) return order === "asc" ? -1 : 1; 4 | if (a[key] > b[key]) return order === "asc" ? 1 : -1; 5 | return 0; 6 | }); 7 | }; 8 | 9 | export const sortByMultipleKeys = (array: T[], keys: { key: keyof T; order: "asc" | "desc" }[]): T[] => { 10 | return [...array].sort((a, b) => { 11 | for (const { key, order } of keys) { 12 | if (a[key] < b[key]) return order === "asc" ? -1 : 1; 13 | if (a[key] > b[key]) return order === "asc" ? 1 : -1; 14 | } 15 | return 0; 16 | }); 17 | }; 18 | 19 | export const sortByCustomComparator = (array: T[], comparator: (a: T, b: T) => number): T[] => { 20 | return [...array].sort(comparator); 21 | }; 22 | 23 | // export const groupBy = (array: T[], key: keyof T): Record => { 24 | // return array.reduce((groups, item) => { 25 | // const value = item[key]; 26 | // groups[value as keyof T] = groups[value as keyof T] || []; 27 | // groups[value as keyof T].push(item); 28 | // return groups; 29 | // }, {} as Record); 30 | // }; 31 | 32 | // export const countBy = (array: T[], key: keyof T): Record => { 33 | // return array.reduce((counts, item) => { 34 | // const value = item[key]; 35 | // counts[value] = (counts[value] || 0) + 1; 36 | // return counts; 37 | // }, {} as Record); 38 | // }; 39 | 40 | // export const sumBy = (array: T[], key: keyof T): number => { 41 | // return array.reduce((sum, item) => sum + item[key], 0); 42 | // }; 43 | 44 | // export const averageBy = (array: T[], key: keyof T): number => { 45 | // return sumBy(array, key) / array.length; 46 | // }; 47 | 48 | export const minBy = (array: T[], key: keyof T): T | undefined => { 49 | return array.reduce((min, item) => (item[key] < min[key] ? item : min), array[0]); 50 | }; 51 | 52 | export const maxBy = (array: T[], key: keyof T): T | undefined => { 53 | return array.reduce((max, item) => (item[key] > max[key] ? item : max), array[0]); 54 | }; 55 | 56 | // export const uniqueBy = (array: T[], key: keyof T): T[] => { 57 | // return Array.from(new Set(array.map((item) => item[key]))); 58 | // }; 59 | 60 | export const shuffleArray = (array: T[]): T[] => { 61 | return array.sort(() => Math.random() - 0.5); 62 | }; 63 | 64 | export const reverseArray = (array: T[]): T[] => { 65 | return array.slice().reverse(); 66 | }; 67 | 68 | export const rotateArray = (array: T[], times: number): T[] => { 69 | return [...array.slice(times), ...array.slice(0, times)]; 70 | }; 71 | 72 | export const chunkArray = (array: T[], size: number): T[][] => { 73 | return Array.from({ length: Math.ceil(array.length / size) }, (_, i) => array.slice(i * size, i * size + size)); 74 | }; 75 | 76 | export const flattenArray = (array: T[][]): T[] => { 77 | return array.reduce((flattened, current) => [...flattened, ...current], []); 78 | }; 79 | 80 | export const compactArray = (array: (T | null | undefined)[]): T[] => { 81 | return array.filter((item) => item != null) as T[]; 82 | }; 83 | 84 | export const removeDuplicates = (array: T[]): T[] => { 85 | return Array.from(new Set(array)); 86 | }; 87 | 88 | export const removeFalsyValues = (array: T[]): T[] => { 89 | return array.filter(Boolean); 90 | }; 91 | 92 | export const removeFalsyAndDuplicates = (array: T[]): T[] => { 93 | return removeDuplicates(removeFalsyValues(array)); 94 | }; 95 | 96 | export const removeItem = (array: T[], item: T): T[] => { 97 | return array.filter((current) => current !== item); 98 | }; 99 | 100 | export const removeItems = (array: T[], items: T[]): T[] => { 101 | return array.filter((current) => !items.includes(current)); 102 | }; 103 | 104 | export const removeItemByIndex = (array: T[], index: number): T[] => { 105 | return [...array.slice(0, index), ...array.slice(index + 1)]; 106 | }; 107 | 108 | export const removeItemsByIndex = (array: T[], indexes: number[]): T[] => { 109 | return array.filter((_, index) => !indexes.includes(index)); 110 | }; 111 | 112 | export const removeItemsByCondition = (array: T[], condition: (item: T) => boolean): T[] => { 113 | return array.filter((item) => !condition(item)); 114 | }; 115 | 116 | export const removeItemsByProperty = (array: T[], key: keyof T, values: T[keyof T][]): T[] => { 117 | return array.filter((item) => !values.includes(item[key])); 118 | }; 119 | 120 | export const removeItemsByProperties = (array: T[], keys: (keyof T)[], values: T[keyof T][]): T[] => { 121 | return array.filter((item) => !keys.some((key) => values.includes(item[key]))); 122 | }; 123 | 124 | export const removeItemsByPropertiesCondition = ( 125 | array: T[], 126 | keys: (keyof T)[], 127 | condition: (values: T[keyof T][]) => boolean, 128 | ): T[] => { 129 | return array.filter((item) => !condition(keys.map((key) => item[key]))); 130 | }; 131 | 132 | export const removeFalsyItems = (array: T[]): T[] => { 133 | return array.filter(Boolean); 134 | }; 135 | 136 | export const removeFalsyItemsByProperty = (array: T[], key: keyof T): T[] => { 137 | return array.filter((item) => Boolean(item[key])); 138 | }; 139 | 140 | export const removeFalsyItemsByProperties = (array: T[], keys: (keyof T)[]): T[] => { 141 | return array.filter((item) => keys.every((key) => Boolean(item[key]))); 142 | }; 143 | 144 | export const removeFalsyItemsByPropertiesCondition = ( 145 | array: T[], 146 | keys: (keyof T)[], 147 | condition: (values: T[keyof T][]) => boolean, 148 | ): T[] => { 149 | return array.filter((item) => condition(keys.map((key) => item[key]))); 150 | }; 151 | -------------------------------------------------------------------------------- /src/dateUtils/formatDate.md: -------------------------------------------------------------------------------- 1 | # formatDate 2 | Formatea una fecha con un patrón específico, con la configuración por defecto de FormatDateOptions = { timezone: 'Europe/Madrid', locale: 'es-ES'} 3 | 4 | ## Formatos soportados 5 | 6 | Se soportan siguentes formatos basados en el estándar Moment.js / Day.js 7 | 8 | const date = new Date("2025-08-04T08:49:29.773Z"); 9 | 10 | | Formato | Significado | Ejemplo | 11 | |---------|---------------------------|---------------| 12 | | YYYY | Año completo | 2025 | 13 | | YY | Año corto | 25 | 14 | | MMMM | Mes completo | agosto | 15 | | MMM | Mes corto | ago | 16 | | MM | Mes en números | 08 | 17 | | DD | Día | 04 | 18 | | dddd | Día de semana completo | lunes | 19 | | ddd | Día de semana corto | lun | 20 | | HH | Hora (24h) | 08 | 21 | | hh | Hora (12h) | 08 | 22 | | mm | Minutos | 49 | 23 | | ss | Segundos | 29 | 24 | | sss | Milisegundos | 773 | 25 | | A | AM/PM | AM | 26 | | a | am/pm | am | 27 | | X | Timestamp Unix | 1754297369 | 28 | | x | Timestamp JavaScript | 1754297369773 | 29 | | ISO_8601 | El estándar ISO 8601 | 2025-08-04T08:49:29Z | 30 | 31 | > **Nota**: También puedes usar template literals con texto literal entre corchetes `[texto]` para crear formatos personalizados que combinen texto con los formatos de fecha y hora. 32 | 33 | | Formato | Ejemplo | 34 | |---------|---------| 35 | | `[Hoy es] dddd[,] DD [de] MMMM` | Hoy es lunes, 23 de agosto | 36 | 37 | 38 | 39 | Ejemplo 40 | ```js 41 | import { formatDate } from 'complete-js-utils'; 42 | 43 | // Formatear fechas 44 | const date = new Date('2023-07-09'); 45 | const result1 = formatDate(date, 'DD/MM/YYYY'); // '09/07/2023' 46 | const result2 = formatDate(date, 'YYYY-MM-DD'); // '2023-07-09' 47 | const result3 = formatDate(date, 'MM-DD-YYYY'); // '07-09-2023' 48 | const result4 = formatDate(date, 'DD.MM.YYYY'); // '09.07.2023' 49 | const result5 = formatDate(date, '[Hoy es] dddd[,] DD [de] MMMM'); // Hoy es domingo, 09 de julio 50 | 51 | ``` 52 | 53 | ## Ejemplos con zonas horarias de España 54 | 55 | ### UTC+2 (CEST) - Horario de verano 56 | Activo en España entre el último domingo de marzo y el último domingo de octubre. 57 | 58 | ```js 59 | import { formatDate } from 'complete-js-utils'; 60 | 61 | const date = new Date('2023-08-23T16:13:37.100+02:00'); 62 | ``` 63 | 64 | | Formato | UTC+2 (CEST) | UTC | Diferencia | 65 | |---------|---------------|-----|------------| 66 | | `YYYY-MM-DD` | 2023-08-23 | 2023-08-23 | 0 días | 67 | | `DD/MM/YYYY` | 23/08/2023 | 23/08/2023 | 0 días | 68 | | `MMMM DD YYYY` | August 23 2023 | August 23 2023 | 0 días | 69 | | `HH:mm:ss` | 16:13:37 | 14:13:37 | +2 horas | 70 | | `hh:mm A` | 04:13 PM | 02:13 PM | +2 horas | 71 | | `sss` | 100 | 100 | 0 ms | 72 | | `X` (timestamp segundos) | 1692800017 | 1692800017 | 0 segundos | 73 | | `x` (timestamp ms) | 1692800017100 | 1692800017100 | 0 ms | 74 | | `ISO_8601` | 2023-08-23T14:13:37.100Z | 2023-08-23T14:13:37.100Z | 0 ms | 75 | 76 | **Template literals:** 77 | | Formato | UTC+2 (CEST) | 78 | |---------|---------------| 79 | | `[Current time is] HH:mm:ss` | Current time is 16:13:37 | 80 | | `[Time:] hh:mm A` | Time: 04:13 PM | 81 | | `HH:mm:ss [UTC+2]` | 16:13:37 UTC+2 | 82 | 83 | ### UTC+1 (CET) - Horario de invierno 84 | Activo en España entre el último domingo de octubre y el último domingo de marzo. 85 | 86 | ```js 87 | import { formatDate } from 'complete-js-utils'; 88 | 89 | const date = new Date('2023-12-15T16:13:37.100+01:00'); 90 | ``` 91 | 92 | | Formato | UTC+1 (CET) | UTC | Diferencia | 93 | |---------|--------------|-----|------------| 94 | | `YYYY-MM-DD` | 2023-12-15 | 2023-12-15 | 0 días | 95 | | `DD/MM/YYYY` | 15/12/2023 | 15/12/2023 | 0 días | 96 | | `MMMM DD YYYY` | December 15 2023 | December 15 2023 | 0 días | 97 | | `HH:mm:ss` | 16:13:37 | 15:13:37 | +1 hora | 98 | | `hh:mm A` | 04:13 PM | 03:13 PM | +1 hora | 99 | | `sss` | 100 | 100 | 0 ms | 100 | | `X` (timestamp segundos) | 1702653217 | 1702653217 | 0 segundos | 101 | | `x` (timestamp ms) | 1702653217100 | 1702653217100 | 0 ms | 102 | | `ISO_8601` | 2023-12-15T15:13:37.100Z | 2023-12-15T15:13:37.100Z | 0 ms | 103 | 104 | ## Personalización 105 | 106 | ### Locales 107 | 108 | Puedes personalizar el idioma de los meses y días usando diferentes locales: 109 | 110 | ```js 111 | import { createDateFormatter } from 'complete-js-utils'; 112 | 113 | const date = new Date('2023-08-23T16:13:37.100Z'); 114 | 115 | // Español (España) 116 | const formatDate_ES = createDateFormatter({locale: "es-ES", timezone: "Europe/Madrid"}); 117 | 118 | // Inglés (Estados Unidos) 119 | const formatDate_EN = createDateFormatter({locale: "en-US", timezone: "Europe/Madrid"}); 120 | ``` 121 | 122 | | Formato | es-ES | en-US | 123 | |---------|-------|-------| 124 | | `MMMM DD YYYY` | agosto 23 2023 | August 23 2023 | 125 | | `MMM DD YYYY` | ago 23 2023 | Aug 23 2023 | 126 | | `dddd DD MMMM YYYY` | miércoles 23 agosto 2023 | Wednesday 23 August 2023 | 127 | | `ddd DD MMM YYYY` | mié 23 ago 2023 | Wed 23 Aug 2023 | 128 | | `[Hoy es] dddd[,] DD [de] MMMM` | Hoy es miércoles, 23 de agosto | Hoy es miércoles, 23 de agosto | 129 | | `[Today is the] DD[th] [of] MMMM` | Today is the 23th of agosto | Today is the 23th of August | 130 | 131 | 132 | ### Timezone 133 | 134 | Puedes especificar diferentes zonas horarias para obtener la hora local: 135 | 136 | ```js 137 | import { createDateFormatter } from 'complete-js-utils'; 138 | 139 | const date = new Date('2023-08-23T16:13:37.100Z'); 140 | 141 | // Madrid, España (UTC+2 en verano) 142 | const formatDate_Madrid = createDateFormatter({locale: "en-US", timezone: "Europe/Madrid"}); 143 | 144 | // Bogotá, Colombia (UTC-5) 145 | const formatDate_Bogota = createDateFormatter({locale: "en-US", timezone: "America/Bogota"}); 146 | ``` 147 | 148 | | Formato | Europe/Madrid | America/Bogota | Diferencia | 149 | |---------|---------------|----------------|------------| 150 | | `YYYY-MM-DD` | 2023-08-23 | 2023-08-23 | 0 días | 151 | | `HH:mm:ss` | 18:13:37 | 11:13:37 | +7 horas | 152 | | `hh:mm A` | 06:13 PM | 11:13 AM | +7 horas | 153 | | `[Current time is] HH:mm:ss` | Current time is 18:13:37 | Current time is 11:13:37 | +7 horas | 154 | | `[Time:] hh:mm A` | Time: 06:13 PM | Time: 11:13 AM | +7 horas | -------------------------------------------------------------------------------- /src/validationUtils.ts: -------------------------------------------------------------------------------- 1 | export const isString = (value: any): value is string => { 2 | return typeof value === "string"; 3 | }; 4 | 5 | export const isNumber = (value: any): value is number => { 6 | return typeof value === "number" && !isNaN(value); 7 | }; 8 | 9 | export const isBoolean = (value: any): value is boolean => { 10 | return typeof value === "boolean"; 11 | }; 12 | 13 | export const isArray = (value: any): value is any[] => { 14 | return Array.isArray(value); 15 | }; 16 | 17 | export const isObject = (value: any): value is object => { 18 | return value !== null && typeof value === "object" && !Array.isArray(value); 19 | }; 20 | 21 | export const isFunction = (value: any): value is Function => { 22 | return typeof value === "function"; 23 | }; 24 | 25 | export const isNull = (value: any): value is null => { 26 | return value === null; 27 | }; 28 | 29 | export const isUndefined = (value: any): value is undefined => { 30 | return value === undefined; 31 | }; 32 | 33 | export const isNil = (value: any): value is null | undefined => { 34 | return value == null; 35 | }; 36 | 37 | export const isDate = (value: any): value is Date => { 38 | return value instanceof Date && !isNaN(value.getTime()); 39 | }; 40 | 41 | export const isRegExp = (value: any): value is RegExp => { 42 | return value instanceof RegExp; 43 | }; 44 | 45 | export const isError = (value: any): value is Error => { 46 | return value instanceof Error; 47 | }; 48 | 49 | export const isNotEmpty = (value: any): boolean => { 50 | if (isNil(value)) return false; 51 | if (isString(value)) return value.trim().length > 0; 52 | if (isArray(value)) return value.length > 0; 53 | if (isObject(value)) return Object.keys(value).length > 0; 54 | if (value instanceof Map || value instanceof Set) return value.size > 0; 55 | return true; 56 | }; 57 | 58 | export const isEmailValid = (value: string): boolean => { 59 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 60 | return emailRegex.test(value); 61 | }; 62 | 63 | export const isUrlValid = (value: string): boolean => { 64 | try { 65 | new URL(value); 66 | return true; 67 | } catch { 68 | return false; 69 | } 70 | }; 71 | 72 | export const isUuid = (value: string): boolean => { 73 | const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; 74 | return uuidRegex.test(value); 75 | }; 76 | 77 | export const isIpAddress = (value: string): boolean => { 78 | const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; 79 | const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/; 80 | return ipv4Regex.test(value) || ipv6Regex.test(value); 81 | }; 82 | 83 | export const isMacAddress = (value: string): boolean => { 84 | const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/; 85 | return macRegex.test(value); 86 | }; 87 | 88 | export const isCreditCard = (value: string): boolean => { 89 | const ccRegex = /^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3[0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})$/; 90 | return ccRegex.test(value.replace(/\s/g, "")); 91 | }; 92 | 93 | export const isPhoneNumber = (value: string, countryCode?: string): boolean => { 94 | const cleaned = value.replace(/[\s\-\(\)]/g, ""); 95 | 96 | if (countryCode === "US") { 97 | return /^(\+1)?[0-9]{10}$/.test(cleaned); 98 | } 99 | if (countryCode === "ES") { 100 | return /^(\+34)?[6-9][0-9]{8}$/.test(cleaned); 101 | } 102 | 103 | // General international format 104 | return /^(\+[1-9]\d{1,14})$/.test(cleaned); 105 | }; 106 | 107 | export const isPostalCode = (value: string, countryCode?: string): boolean => { 108 | const patterns: Record = { 109 | US: /^\d{5}(-\d{4})?$/, 110 | ES: /^\d{5}$/, 111 | UK: /^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$/i, 112 | CA: /^[A-Z]\d[A-Z]\s?\d[A-Z]\d$/i, 113 | DE: /^\d{5}$/, 114 | FR: /^\d{5}$/, 115 | }; 116 | 117 | if (countryCode && patterns[countryCode]) { 118 | return patterns[countryCode].test(value); 119 | } 120 | 121 | // General pattern 122 | return /^[A-Z0-9\s\-]{3,10}$/i.test(value); 123 | }; 124 | 125 | export const isHexColor = (value: string): boolean => { 126 | return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(value); 127 | }; 128 | 129 | export const isBase64 = (value: string): boolean => { 130 | try { 131 | return btoa(atob(value)) === value; 132 | } catch { 133 | return false; 134 | } 135 | }; 136 | 137 | export const isJson = (value: string): boolean => { 138 | try { 139 | JSON.parse(value); 140 | return true; 141 | } catch { 142 | return false; 143 | } 144 | }; 145 | 146 | export const isPositive = (value: number): boolean => { 147 | return isNumber(value) && value > 0; 148 | }; 149 | 150 | export const isNegative = (value: number): boolean => { 151 | return isNumber(value) && value < 0; 152 | }; 153 | 154 | export const isZero = (value: number): boolean => { 155 | return isNumber(value) && value === 0; 156 | }; 157 | 158 | export const isInteger = (value: number): boolean => { 159 | return isNumber(value) && Number.isInteger(value); 160 | }; 161 | 162 | export const isFloat = (value: number): boolean => { 163 | return isNumber(value) && !Number.isInteger(value); 164 | }; 165 | 166 | export const isEvenNumber = (value: number): boolean => { 167 | return isInteger(value) && value % 2 === 0; 168 | }; 169 | 170 | export const isOddNumber = (value: number): boolean => { 171 | return isInteger(value) && value % 2 !== 0; 172 | }; 173 | 174 | export const isPrimeNumber = (value: number): boolean => { 175 | if (!isInteger(value) || value < 2) return false; 176 | for (let i = 2; i <= Math.sqrt(value); i++) { 177 | if (value % i === 0) return false; 178 | } 179 | return true; 180 | }; 181 | 182 | export const isInRange = (value: number, min: number, max: number, inclusive: boolean = true): boolean => { 183 | if (!isNumber(value)) return false; 184 | return inclusive ? value >= min && value <= max : value > min && value < max; 185 | }; 186 | 187 | export const isAlpha = (value: string): boolean => { 188 | return /^[a-zA-Z]+$/.test(value); 189 | }; 190 | 191 | export const isAlphanumeric = (value: string): boolean => { 192 | return /^[a-zA-Z0-9]+$/.test(value); 193 | }; 194 | 195 | export const isNumeric = (value: string): boolean => { 196 | return /^-?\d*\.?\d+$/.test(value) && !isNaN(Number(value)); 197 | }; 198 | 199 | export const isLowercase = (value: string): boolean => { 200 | return value === value.toLowerCase(); 201 | }; 202 | 203 | export const isUppercase = (value: string): boolean => { 204 | return value === value.toUpperCase(); 205 | }; 206 | 207 | export const hasLength = (value: string | any[], min?: number, max?: number): boolean => { 208 | const length = value.length; 209 | if (min !== undefined && length < min) return false; 210 | if (max !== undefined && length > max) return false; 211 | return true; 212 | }; 213 | 214 | export const matchesPattern = (value: string, pattern: RegExp): boolean => { 215 | return pattern.test(value); 216 | }; 217 | 218 | export const isStrongPassword = (value: string): boolean => { 219 | // At least 8 characters, 1 uppercase, 1 lowercase, 1 number, 1 special char 220 | const strongRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/; 221 | return strongRegex.test(value); 222 | }; 223 | 224 | export const isWeakPassword = (value: string): boolean => { 225 | return !isStrongPassword(value); 226 | }; 227 | -------------------------------------------------------------------------------- /src/numberUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Number utility functions 3 | */ 4 | 5 | export const clamp = (num: number, min: number, max: number): number => { 6 | return Math.min(Math.max(num, min), max); 7 | }; 8 | 9 | export const random = (min: number, max: number): number => { 10 | return Math.random() * (max - min) + min; 11 | }; 12 | 13 | export const randomInt = (min: number, max: number): number => { 14 | return Math.floor(Math.random() * (max - min + 1)) + min; 15 | }; 16 | 17 | export const round = (num: number, decimals: number = 0): number => { 18 | const factor = Math.pow(10, decimals); 19 | return Math.round(num * factor) / factor; 20 | }; 21 | 22 | export const toFixed = (num: number, decimals: number): string => { 23 | return num.toFixed(decimals); 24 | }; 25 | 26 | export const isEven = (num: number): boolean => { 27 | return num % 2 === 0; 28 | }; 29 | 30 | export const isOdd = (num: number): boolean => { 31 | return num % 2 !== 0; 32 | }; 33 | 34 | export const isPrime = (num: number): boolean => { 35 | if (num < 2) return false; 36 | for (let i = 2; i <= Math.sqrt(num); i++) { 37 | if (num % i === 0) return false; 38 | } 39 | return true; 40 | }; 41 | 42 | export const factorial = (num: number): number => { 43 | if (num < 0) return -1; 44 | if (num === 0) return 1; 45 | return num * factorial(num - 1); 46 | }; 47 | 48 | export const fibonacci = (n: number): number => { 49 | if (n <= 1) return n; 50 | return fibonacci(n - 1) + fibonacci(n - 2); 51 | }; 52 | 53 | export const gcd = (a: number, b: number): number => { 54 | return b === 0 ? a : gcd(b, a % b); 55 | }; 56 | 57 | export const lcm = (a: number, b: number): number => { 58 | return Math.abs(a * b) / gcd(a, b); 59 | }; 60 | 61 | export const percentage = (value: number, total: number): number => { 62 | // Avoid division by zero; define percentage as 0 when total is 0 63 | if (total === 0) return 0; 64 | return (value / total) * 100; 65 | }; 66 | 67 | export const percentageOf = (percent: number, total: number): number => { 68 | return (percent / 100) * total; 69 | }; 70 | 71 | export const average = (numbers: number[]): number => { 72 | if (numbers.length === 0) return 0; 73 | return sum(numbers) / numbers.length; 74 | }; 75 | 76 | export const median = (numbers: number[]): number => { 77 | const sorted = [...numbers].sort((a, b) => a - b); 78 | const mid = Math.floor(sorted.length / 2); 79 | return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; 80 | }; 81 | 82 | export const mode = (numbers: number[]): number[] => { 83 | const frequency: { [key: number]: number } = {}; 84 | let maxFreq = 0; 85 | 86 | numbers.forEach((num) => { 87 | frequency[num] = (frequency[num] || 0) + 1; 88 | maxFreq = Math.max(maxFreq, frequency[num]); 89 | }); 90 | 91 | return Object.keys(frequency) 92 | .filter((key) => frequency[Number(key)] === maxFreq) 93 | .map(Number); 94 | }; 95 | 96 | export const sum = (numbers: number[]): number => { 97 | return numbers.reduce((total, num) => total + num, 0); 98 | }; 99 | 100 | export const product = (numbers: number[]): number => { 101 | return numbers.reduce((total, num) => total * num, 1); 102 | }; 103 | 104 | export const max = (numbers: number[]): number => { 105 | return Math.max(...numbers); 106 | }; 107 | 108 | export const min = (numbers: number[]): number => { 109 | return Math.min(...numbers); 110 | }; 111 | 112 | export const range = (numbers: number[]): number => { 113 | return max(numbers) - min(numbers); 114 | }; 115 | 116 | export const standardDeviation = (numbers: number[]): number => { 117 | const avg = average(numbers); 118 | const squaredDiffs = numbers.map((num) => Math.pow(num - avg, 2)); 119 | return Math.sqrt(average(squaredDiffs)); 120 | }; 121 | 122 | export const variance = (numbers: number[]): number => { 123 | const avg = average(numbers); 124 | const squaredDiffs = numbers.map((num) => Math.pow(num - avg, 2)); 125 | return average(squaredDiffs); 126 | }; 127 | 128 | export const toRadians = (degrees: number): number => { 129 | return degrees * (Math.PI / 180); 130 | }; 131 | 132 | export const toDegrees = (radians: number): number => { 133 | return radians * (180 / Math.PI); 134 | }; 135 | 136 | export const formatNumber = (num: number, locale: string = "en-US"): string => { 137 | return new Intl.NumberFormat(locale).format(num); 138 | }; 139 | 140 | export const formatCurrency = (num: number, currency: string = "USD", locale: string = "en-US"): string => { 141 | return new Intl.NumberFormat(locale, { 142 | style: "currency", 143 | currency: currency, 144 | }).format(num); 145 | }; 146 | 147 | export const formatPercent = (num: number, locale: string = "en-US"): string => { 148 | return new Intl.NumberFormat(locale, { 149 | style: "percent", 150 | }).format(num); 151 | }; 152 | 153 | export const lerp = (start: number, end: number, t: number): number => { 154 | return start + (end - start) * t; 155 | }; 156 | 157 | export const map = (value: number, inMin: number, inMax: number, outMin: number, outMax: number): number => { 158 | // Handle zero-length input range to avoid division by zero; choose outMin by convention 159 | if (inMax === inMin) return outMin; 160 | return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; 161 | }; 162 | 163 | export const inRange = (num: number, min: number, max: number): boolean => { 164 | return num >= min && num <= max; 165 | }; 166 | 167 | /** 168 | * Converts a currency-formatted string into a number. 169 | * Removes symbols, spaces, and thousand separators. 170 | */ 171 | export function parseCurrency(value?: string, locale: "us" | "eu" = "us"): number { 172 | if (!value) return NaN 173 | 174 | let cleaned = value.replace(/[^\d.,-]/g, "").trim() 175 | const hasComma = cleaned.includes(",") 176 | const hasDot = cleaned.includes(".") 177 | 178 | let normalized = cleaned 179 | 180 | if (hasComma && hasDot) { 181 | if (cleaned.lastIndexOf(",") > cleaned.lastIndexOf(".")) { 182 | normalized = cleaned.replace(/\./g, "").replace(",", ".") 183 | } else { 184 | normalized = cleaned.replace(/,/g, "") 185 | } 186 | } else if (hasComma) { 187 | const parts = cleaned.split(",") 188 | if (parts[1]?.length === 2) { 189 | normalized = cleaned.replace(",", ".") 190 | } else { 191 | normalized = cleaned.replace(/,/g, "") 192 | } 193 | } else if (hasDot) { 194 | const parts = cleaned.split(".") 195 | if (parts[1]?.length === 2) { 196 | normalized = cleaned 197 | } else { 198 | normalized = locale === "eu" ? cleaned.replace(/\./g, "") : cleaned 199 | } 200 | } 201 | 202 | return parseFloat(normalized) 203 | } 204 | 205 | /** 206 | * Counts the number of decimal places of a number using math only, 207 | * ignoring floating-point artifacts. 208 | * 209 | * @param value The number to evaluate 210 | * @param maxDecimals Maximum decimals to check (default 16, the precision limit of IEEE-754 double) 211 | */ 212 | export function countDecimals(value: number, maxDecimals: number = 16): number { 213 | if (!Number.isFinite(value) || Number.isNaN(value)) return 0; 214 | 215 | let e = 1; 216 | let count = 0; 217 | 218 | while (count < maxDecimals) { 219 | const multiplied = value * e; 220 | 221 | // Use a tolerance to avoid floating-point precision issues 222 | if (Math.abs(Math.round(multiplied) - multiplied) < Number.EPSILON * e) { 223 | break; 224 | } 225 | 226 | e *= 10; 227 | count++; 228 | } 229 | 230 | return count; 231 | } 232 | -------------------------------------------------------------------------------- /src/urlUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * URL utility functions 3 | */ 4 | 5 | export const parseUrl = (url: string): URL | null => { 6 | try { 7 | return new URL(url); 8 | } catch { 9 | return null; 10 | } 11 | }; 12 | 13 | export const isValidUrl = (url: string): boolean => { 14 | return parseUrl(url) !== null; 15 | }; 16 | 17 | export const getQueryParams = (url: string): Record => { 18 | const parsedUrl = parseUrl(url); 19 | if (!parsedUrl) return {}; 20 | 21 | const params: Record = {}; 22 | parsedUrl.searchParams.forEach((value, key) => { 23 | params[key] = value; 24 | }); 25 | 26 | return params; 27 | }; 28 | 29 | export const addQueryParams = (url: string, params: Record): string => { 30 | const parsedUrl = parseUrl(url); 31 | if (!parsedUrl) return url; 32 | 33 | Object.entries(params).forEach(([key, value]) => { 34 | parsedUrl.searchParams.set(key, value); 35 | }); 36 | 37 | return parsedUrl.toString(); 38 | }; 39 | 40 | export const removeQueryParams = (url: string, paramNames: string[]): string => { 41 | const parsedUrl = parseUrl(url); 42 | if (!parsedUrl) return url; 43 | 44 | paramNames.forEach((name) => { 45 | parsedUrl.searchParams.delete(name); 46 | }); 47 | 48 | return parsedUrl.toString(); 49 | }; 50 | 51 | export const getQueryParam = (url: string, paramName: string): string | null => { 52 | const parsedUrl = parseUrl(url); 53 | if (!parsedUrl) return null; 54 | 55 | return parsedUrl.searchParams.get(paramName); 56 | }; 57 | 58 | export const hasQueryParam = (url: string, paramName: string): boolean => { 59 | const parsedUrl = parseUrl(url); 60 | if (!parsedUrl) return false; 61 | 62 | return parsedUrl.searchParams.has(paramName); 63 | }; 64 | 65 | export const clearQueryParams = (url: string): string => { 66 | const parsedUrl = parseUrl(url); 67 | if (!parsedUrl) return url; 68 | 69 | parsedUrl.search = ""; 70 | return parsedUrl.toString(); 71 | }; 72 | 73 | export const getDomain = (url: string): string | null => { 74 | const parsedUrl = parseUrl(url); 75 | return parsedUrl ? parsedUrl.hostname : null; 76 | }; 77 | 78 | export const getProtocol = (url: string): string | null => { 79 | const parsedUrl = parseUrl(url); 80 | return parsedUrl ? parsedUrl.protocol : null; 81 | }; 82 | 83 | export const getPort = (url: string): string | null => { 84 | const parsedUrl = parseUrl(url); 85 | return parsedUrl ? parsedUrl.port : null; 86 | }; 87 | 88 | export const getPath = (url: string): string | null => { 89 | const parsedUrl = parseUrl(url); 90 | return parsedUrl ? parsedUrl.pathname : null; 91 | }; 92 | 93 | export const getHash = (url: string): string | null => { 94 | const parsedUrl = parseUrl(url); 95 | return parsedUrl ? parsedUrl.hash : null; 96 | }; 97 | 98 | export const buildUrl = (options: { 99 | protocol?: string; 100 | hostname: string; 101 | port?: string | number; 102 | pathname?: string; 103 | search?: string; 104 | hash?: string; 105 | }): string => { 106 | const url = new URL(`${options.protocol || "https"}://${options.hostname}`); 107 | 108 | if (options.port) { 109 | url.port = String(options.port); 110 | } 111 | 112 | if (options.pathname) { 113 | url.pathname = options.pathname; 114 | } 115 | 116 | if (options.search) { 117 | url.search = options.search.startsWith("?") ? options.search : `?${options.search}`; 118 | } 119 | 120 | if (options.hash) { 121 | url.hash = options.hash.startsWith("#") ? options.hash : `#${options.hash}`; 122 | } 123 | 124 | return url.toString(); 125 | }; 126 | 127 | export const joinPaths = (...paths: string[]): string => { 128 | return paths 129 | .map((path) => path.replace(/^\/+|\/+$/g, "")) 130 | .filter((path) => path.length > 0) 131 | .join("/"); 132 | }; 133 | 134 | export const isAbsoluteUrl = (url: string): boolean => { 135 | return /^https?:\/\//i.test(url); 136 | }; 137 | 138 | export const isRelativeUrl = (url: string): boolean => { 139 | return !isAbsoluteUrl(url); 140 | }; 141 | 142 | export const makeAbsolute = (relativeUrl: string, baseUrl: string): string => { 143 | if (isAbsoluteUrl(relativeUrl)) { 144 | return relativeUrl; 145 | } 146 | 147 | try { 148 | return new URL(relativeUrl, baseUrl).toString(); 149 | } catch { 150 | return relativeUrl; 151 | } 152 | }; 153 | 154 | export const isSameOrigin = (url1: string, url2: string): boolean => { 155 | const parsed1 = parseUrl(url1); 156 | const parsed2 = parseUrl(url2); 157 | 158 | if (!parsed1 || !parsed2) return false; 159 | 160 | return parsed1.origin === parsed2.origin; 161 | }; 162 | 163 | export const isSameDomain = (url1: string, url2: string): boolean => { 164 | const parsed1 = parseUrl(url1); 165 | const parsed2 = parseUrl(url2); 166 | 167 | if (!parsed1 || !parsed2) return false; 168 | 169 | return parsed1.hostname === parsed2.hostname; 170 | }; 171 | 172 | export const isSecure = (url: string): boolean => { 173 | const parsedUrl = parseUrl(url); 174 | return parsedUrl ? parsedUrl.protocol === "https:" : false; 175 | }; 176 | 177 | export const makeSecure = (url: string): string => { 178 | const parsedUrl = parseUrl(url); 179 | if (!parsedUrl) return url; 180 | 181 | parsedUrl.protocol = "https:"; 182 | return parsedUrl.toString(); 183 | }; 184 | 185 | export const makeInsecure = (url: string): string => { 186 | const parsedUrl = parseUrl(url); 187 | if (!parsedUrl) return url; 188 | 189 | parsedUrl.protocol = "http:"; 190 | return parsedUrl.toString(); 191 | }; 192 | 193 | export const encodeQueryString = (params: Record): string => { 194 | return Object.entries(params) 195 | .map(([key, value]) => { 196 | const encodedKey = encodeURIComponent(key); 197 | const encodedValue = encodeURIComponent(String(value)); 198 | return `${encodedKey}=${encodedValue}`; 199 | }) 200 | .join("&"); 201 | }; 202 | 203 | export const decodeQueryString = (queryString: string): Record => { 204 | const params: Record = {}; 205 | const cleanQuery = queryString.replace(/^\?/, ""); 206 | 207 | if (!cleanQuery) return params; 208 | 209 | cleanQuery.split("&").forEach((pair) => { 210 | const [key, value] = pair.split("=").map(decodeURIComponent); 211 | if (key) { 212 | params[key] = value || ""; 213 | } 214 | }); 215 | 216 | return params; 217 | }; 218 | 219 | export const getFileExtension = (url: string): string | null => { 220 | const parsedUrl = parseUrl(url); 221 | if (!parsedUrl) return null; 222 | 223 | const pathname = parsedUrl.pathname; 224 | const lastDot = pathname.lastIndexOf("."); 225 | 226 | if (lastDot === -1) return null; 227 | 228 | return pathname.slice(lastDot + 1); 229 | }; 230 | 231 | export const removeFileExtension = (url: string): string => { 232 | const parsedUrl = parseUrl(url); 233 | if (!parsedUrl) return url; 234 | 235 | const pathname = parsedUrl.pathname; 236 | const lastDot = pathname.lastIndexOf("."); 237 | 238 | if (lastDot !== -1) { 239 | parsedUrl.pathname = pathname.slice(0, lastDot); 240 | } 241 | 242 | return parsedUrl.toString(); 243 | }; 244 | 245 | export const isImageUrl = (url: string): boolean => { 246 | const extension = getFileExtension(url); 247 | if (!extension) return false; 248 | 249 | const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico"]; 250 | return imageExtensions.includes(extension.toLowerCase()); 251 | }; 252 | 253 | export const isVideoUrl = (url: string): boolean => { 254 | const extension = getFileExtension(url); 255 | if (!extension) return false; 256 | 257 | const videoExtensions = ["mp4", "avi", "mov", "wmv", "flv", "webm", "mkv"]; 258 | return videoExtensions.includes(extension.toLowerCase()); 259 | }; 260 | 261 | export const isAudioUrl = (url: string): boolean => { 262 | const extension = getFileExtension(url); 263 | if (!extension) return false; 264 | 265 | const audioExtensions = ["mp3", "wav", "ogg", "aac", "flac", "m4a"]; 266 | return audioExtensions.includes(extension.toLowerCase()); 267 | }; 268 | -------------------------------------------------------------------------------- /src/arrayUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extended array utility functions 3 | */ 4 | 5 | export const groupByKey = (array: T[], key: keyof T): Record => { 6 | return array.reduce((groups, item) => { 7 | const value = String(item[key]); 8 | if (!groups[value]) { 9 | groups[value] = []; 10 | } 11 | groups[value].push(item); 12 | return groups; 13 | }, {} as Record); 14 | }; 15 | 16 | export const countByKey = (array: T[], key: keyof T): Record => { 17 | return array.reduce((counts, item) => { 18 | const value = String(item[key]); 19 | counts[value] = (counts[value] || 0) + 1; 20 | return counts; 21 | }, {} as Record); 22 | }; 23 | 24 | export const sumBy = (array: T[], key: keyof T): number => { 25 | return array.reduce((sum, item) => sum + Number(item[key]), 0); 26 | }; 27 | 28 | export const averageBy = (array: T[], key: keyof T): number => { 29 | if (array.length === 0) return 0; 30 | return sumBy(array, key) / array.length; 31 | }; 32 | 33 | export const uniqueBy = (array: T[], key: keyof T): T[] => { 34 | const seen = new Set(); 35 | return array.filter((item) => { 36 | const value = item[key]; 37 | if (seen.has(value)) { 38 | return false; 39 | } 40 | seen.add(value); 41 | return true; 42 | }); 43 | }; 44 | 45 | export const intersectionBy = (array1: T[], array2: T[], key: keyof T): T[] => { 46 | const set2 = new Set(array2.map((item) => item[key])); 47 | return array1.filter((item) => set2.has(item[key])); 48 | }; 49 | 50 | export const differenceBy = (array1: T[], array2: T[], key: keyof T): T[] => { 51 | const set2 = new Set(array2.map((item) => item[key])); 52 | return array1.filter((item) => !set2.has(item[key])); 53 | }; 54 | 55 | export const unionBy = (array1: T[], array2: T[], key: keyof T): T[] => { 56 | const seen = new Set(); 57 | const result: T[] = []; 58 | 59 | [...array1, ...array2].forEach((item) => { 60 | const value = item[key]; 61 | if (!seen.has(value)) { 62 | seen.add(value); 63 | result.push(item); 64 | } 65 | }); 66 | 67 | return result; 68 | }; 69 | 70 | export const partition = (array: T[], predicate: (item: T) => boolean): [T[], T[]] => { 71 | const truthy: T[] = []; 72 | const falsy: T[] = []; 73 | 74 | array.forEach((item) => { 75 | if (predicate(item)) { 76 | truthy.push(item); 77 | } else { 78 | falsy.push(item); 79 | } 80 | }); 81 | 82 | return [truthy, falsy]; 83 | }; 84 | 85 | export const sample = (array: T[]): T | undefined => { 86 | if (array.length === 0) return undefined; 87 | return array[Math.floor(Math.random() * array.length)]; 88 | }; 89 | 90 | export const sampleSize = (array: T[], n: number): T[] => { 91 | const shuffled = [...array]; 92 | for (let i = shuffled.length - 1; i > 0; i--) { 93 | const j = Math.floor(Math.random() * (i + 1)); 94 | [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 95 | } 96 | return shuffled.slice(0, n); 97 | }; 98 | 99 | export const takeWhile = (array: T[], predicate: (item: T) => boolean): T[] => { 100 | const result: T[] = []; 101 | for (const item of array) { 102 | if (!predicate(item)) break; 103 | result.push(item); 104 | } 105 | return result; 106 | }; 107 | 108 | export const dropWhile = (array: T[], predicate: (item: T) => boolean): T[] => { 109 | let index = 0; 110 | while (index < array.length && predicate(array[index])) { 111 | index++; 112 | } 113 | return array.slice(index); 114 | }; 115 | 116 | export const findIndex = (array: T[], predicate: (item: T) => boolean): number => { 117 | for (let i = 0; i < array.length; i++) { 118 | if (predicate(array[i])) { 119 | return i; 120 | } 121 | } 122 | return -1; 123 | }; 124 | 125 | export const findLastIndex = (array: T[], predicate: (item: T) => boolean): number => { 126 | for (let i = array.length - 1; i >= 0; i--) { 127 | if (predicate(array[i])) { 128 | return i; 129 | } 130 | } 131 | return -1; 132 | }; 133 | 134 | export const first = (array: T[]): T | undefined => { 135 | return array[0]; 136 | }; 137 | 138 | export const last = (array: T[]): T | undefined => { 139 | return array[array.length - 1]; 140 | }; 141 | 142 | export const nth = (array: T[], index: number): T | undefined => { 143 | if (index < 0) { 144 | return array[array.length + index]; 145 | } 146 | return array[index]; 147 | }; 148 | 149 | export const pull = (array: T[], ...values: T[]): T[] => { 150 | return array.filter((item) => !values.includes(item)); 151 | }; 152 | 153 | export const pullAt = (array: T[], indexes: number[]): T[] => { 154 | const result: T[] = []; 155 | const sortedIndexes = [...indexes].sort((a, b) => b - a); 156 | 157 | sortedIndexes.forEach((index) => { 158 | if (index >= 0 && index < array.length) { 159 | result.unshift(array.splice(index, 1)[0]); 160 | } 161 | }); 162 | 163 | return result.reverse(); 164 | }; 165 | 166 | export const zip = (array1: T[], array2: U[]): Array<[T, U]> => { 167 | const length = Math.min(array1.length, array2.length); 168 | const result: Array<[T, U]> = []; 169 | 170 | for (let i = 0; i < length; i++) { 171 | result.push([array1[i], array2[i]]); 172 | } 173 | 174 | return result; 175 | }; 176 | 177 | export const unzip = (array: Array<[T, U]>): [T[], U[]] => { 178 | const result1: T[] = []; 179 | const result2: U[] = []; 180 | 181 | array.forEach(([first, second]) => { 182 | result1.push(first); 183 | result2.push(second); 184 | }); 185 | 186 | return [result1, result2]; 187 | }; 188 | 189 | export const zipWith = (array1: T[], array2: U[], fn: (a: T, b: U) => R): R[] => { 190 | const length = Math.min(array1.length, array2.length); 191 | const result: R[] = []; 192 | 193 | for (let i = 0; i < length; i++) { 194 | result.push(fn(array1[i], array2[i])); 195 | } 196 | 197 | return result; 198 | }; 199 | 200 | export const xor = (array1: T[], array2: T[]): T[] => { 201 | const set1 = new Set(array1); 202 | const set2 = new Set(array2); 203 | 204 | return [...array1.filter((item) => !set2.has(item)), ...array2.filter((item) => !set1.has(item))]; 205 | }; 206 | 207 | export const move = (array: T[], fromIndex: number, toIndex: number): T[] => { 208 | const result = [...array]; 209 | const [removed] = result.splice(fromIndex, 1); 210 | result.splice(toIndex, 0, removed); 211 | return result; 212 | }; 213 | 214 | export const transpose = (matrix: T[][]): T[][] => { 215 | if (matrix.length === 0) return []; 216 | const rows = matrix.length; 217 | const cols = matrix[0].length; 218 | const result: T[][] = []; 219 | 220 | for (let j = 0; j < cols; j++) { 221 | result[j] = []; 222 | for (let i = 0; i < rows; i++) { 223 | result[j][i] = matrix[i][j]; 224 | } 225 | } 226 | 227 | return result; 228 | }; 229 | 230 | export const isSubset = (subset: T[], superset: T[]): boolean => { 231 | return subset.every((item) => superset.includes(item)); 232 | }; 233 | 234 | export const isSuperset = (superset: T[], subset: T[]): boolean => { 235 | return isSubset(subset, superset); 236 | }; 237 | 238 | export const frequency = (array: T[]): Map => { 239 | const freq = new Map(); 240 | array.forEach((item) => { 241 | freq.set(item, (freq.get(item) || 0) + 1); 242 | }); 243 | return freq; 244 | }; 245 | 246 | export const mostFrequent = (array: T[]): T | undefined => { 247 | const freq = frequency(array); 248 | let maxCount = 0; 249 | let result: T | undefined; 250 | 251 | freq.forEach((count, item) => { 252 | if (count > maxCount) { 253 | maxCount = count; 254 | result = item; 255 | } 256 | }); 257 | 258 | return result; 259 | }; 260 | 261 | export const leastFrequent = (array: T[]): T | undefined => { 262 | const freq = frequency(array); 263 | let minCount = Infinity; 264 | let result: T | undefined; 265 | 266 | freq.forEach((count, item) => { 267 | if (count < minCount) { 268 | minCount = count; 269 | result = item; 270 | } 271 | }); 272 | 273 | return result; 274 | }; 275 | -------------------------------------------------------------------------------- /src/objectUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Object utility functions 3 | */ 4 | 5 | import { camelCase, snakeCase } from "./stringUtils"; 6 | 7 | export const clone = (obj: T): T => { 8 | if (obj === null || typeof obj !== "object") return obj; 9 | if (obj instanceof Date) return new Date(obj.getTime()) as unknown as T; 10 | if (obj instanceof Array) return obj.map((item) => clone(item)) as unknown as T; 11 | if (obj instanceof Object) { 12 | const clonedObj: any = {}; 13 | for (const key in obj) { 14 | if (obj.hasOwnProperty(key)) { 15 | clonedObj[key] = clone(obj[key]); 16 | } 17 | } 18 | return clonedObj as T; 19 | } 20 | return obj; 21 | }; 22 | 23 | export const merge = (target: T, source: U): T & U => { 24 | const result = { ...target } as T & U; 25 | 26 | for (const key in source) { 27 | if (source.hasOwnProperty(key)) { 28 | const sourceValue = source[key]; 29 | const targetValue = (result as any)[key]; 30 | 31 | if (isPlainObject(sourceValue) && isPlainObject(targetValue)) { 32 | (result as any)[key] = merge(targetValue, sourceValue); 33 | } else { 34 | (result as any)[key] = sourceValue; 35 | } 36 | } 37 | } 38 | 39 | return result; 40 | }; 41 | 42 | export const pick = (obj: T, keys: K[]): Pick => { 43 | const result = {} as Pick; 44 | keys.forEach((key) => { 45 | if (key in obj) { 46 | result[key] = obj[key]; 47 | } 48 | }); 49 | return result; 50 | }; 51 | 52 | export const omit = (obj: T, keys: K[]): Omit => { 53 | const result = { ...obj }; 54 | keys.forEach((key) => { 55 | delete result[key]; 56 | }); 57 | return result; 58 | }; 59 | 60 | export const get = (obj: any, path: string, defaultValue?: T): T => { 61 | const keys = path.split("."); 62 | let result = obj; 63 | 64 | for (const key of keys) { 65 | if (result == null || typeof result !== "object") { 66 | return defaultValue as T; 67 | } 68 | result = result[key]; 69 | } 70 | 71 | return result !== undefined ? result : (defaultValue as T); 72 | }; 73 | 74 | export const set = (obj: any, path: string, value: any): void => { 75 | const keys = path.split("."); 76 | let current = obj; 77 | 78 | for (let i = 0; i < keys.length - 1; i++) { 79 | const key = keys[i]; 80 | if (!(key in current) || typeof current[key] !== "object") { 81 | current[key] = {}; 82 | } 83 | current = current[key]; 84 | } 85 | 86 | current[keys[keys.length - 1]] = value; 87 | }; 88 | 89 | export const has = (obj: any, path: string): boolean => { 90 | const keys = path.split("."); 91 | let current = obj; 92 | 93 | for (const key of keys) { 94 | if (current == null || typeof current !== "object" || !(key in current)) { 95 | return false; 96 | } 97 | current = current[key]; 98 | } 99 | 100 | return true; 101 | }; 102 | 103 | export const isEmpty = (obj: any): boolean => { 104 | if (obj == null) return true; 105 | if (Array.isArray(obj) || typeof obj === "string") return obj.length === 0; 106 | if (obj instanceof Map || obj instanceof Set) return obj.size === 0; 107 | return Object.keys(obj).length === 0; 108 | }; 109 | 110 | export const isEqual = (a: any, b: any): boolean => { 111 | if (a === b) return true; 112 | if (a == null || b == null) return false; 113 | if (typeof a !== typeof b) return false; 114 | 115 | if (Array.isArray(a) && Array.isArray(b)) { 116 | if (a.length !== b.length) return false; 117 | return a.every((item, index) => isEqual(item, b[index])); 118 | } 119 | 120 | if (isPlainObject(a) && isPlainObject(b)) { 121 | const keysA = Object.keys(a); 122 | const keysB = Object.keys(b); 123 | if (keysA.length !== keysB.length) return false; 124 | return keysA.every((key) => isEqual((a as any)[key], (b as any)[key])); 125 | } 126 | 127 | return false; 128 | }; 129 | 130 | export const keys = (obj: T): Array => { 131 | return Object.keys(obj) as Array; 132 | }; 133 | 134 | export const values = (obj: T): Array => { 135 | return Object.values(obj); 136 | }; 137 | 138 | export const entries = (obj: T): Array<[keyof T, T[keyof T]]> => { 139 | return Object.entries(obj) as Array<[keyof T, T[keyof T]]>; 140 | }; 141 | 142 | export const fromEntries = (entries: Array<[K, V]>): Record => { 143 | return Object.fromEntries(entries) as Record; 144 | }; 145 | 146 | export const mapValues = ( 147 | obj: T, 148 | fn: (value: T[keyof T], key: keyof T) => U, 149 | ): Record => { 150 | const result = {} as Record; 151 | for (const key in obj) { 152 | if (obj.hasOwnProperty(key)) { 153 | result[key] = fn(obj[key], key); 154 | } 155 | } 156 | return result; 157 | }; 158 | 159 | export const mapKeys = ( 160 | obj: T, 161 | fn: (value: T[keyof T], key: keyof T) => K, 162 | ): Record => { 163 | const result = {} as Record; 164 | for (const key in obj) { 165 | if (obj.hasOwnProperty(key)) { 166 | const newKey = fn(obj[key], key); 167 | result[newKey] = obj[key]; 168 | } 169 | } 170 | return result; 171 | }; 172 | 173 | export const invert = >(obj: T): Record => { 174 | const result = {} as Record; 175 | for (const key in obj) { 176 | if (obj.hasOwnProperty(key)) { 177 | result[obj[key]] = key; 178 | } 179 | } 180 | return result; 181 | }; 182 | 183 | export const groupBy = (array: T[], fn: (item: T) => string | number): Record => { 184 | return array.reduce((groups, item) => { 185 | const key = fn(item); 186 | if (!groups[key]) { 187 | groups[key] = []; 188 | } 189 | groups[key].push(item); 190 | return groups; 191 | }, {} as Record); 192 | }; 193 | 194 | export const countBy = (array: T[], fn: (item: T) => string | number): Record => { 195 | return array.reduce((counts, item) => { 196 | const key = fn(item); 197 | counts[key] = (counts[key] || 0) + 1; 198 | return counts; 199 | }, {} as Record); 200 | }; 201 | 202 | export const indexBy = (array: T[], fn: (item: T) => string | number): Record => { 203 | return array.reduce((index, item) => { 204 | const key = fn(item); 205 | index[key] = item; 206 | return index; 207 | }, {} as Record); 208 | }; 209 | 210 | export const flatten = (obj: any, separator: string = "."): Record => { 211 | const result: Record = {}; 212 | 213 | function flattenRecursive(current: any, prefix: string = "") { 214 | for (const key in current) { 215 | if (current.hasOwnProperty(key)) { 216 | const newKey = prefix ? `${prefix}${separator}${key}` : key; 217 | if (isPlainObject(current[key])) { 218 | flattenRecursive(current[key], newKey); 219 | } else { 220 | result[newKey] = current[key]; 221 | } 222 | } 223 | } 224 | } 225 | 226 | flattenRecursive(obj); 227 | return result; 228 | }; 229 | 230 | export const unflatten = (obj: Record, separator: string = "."): any => { 231 | const result: any = {}; 232 | 233 | for (const key in obj) { 234 | if (obj.hasOwnProperty(key)) { 235 | set(result, key.replace(new RegExp(`\\${separator}`, "g"), "."), obj[key]); 236 | } 237 | } 238 | 239 | return result; 240 | }; 241 | 242 | // Helper function 243 | const isPlainObject = (value: any): value is object => { 244 | return value != null && typeof value === "object" && value.constructor === Object; 245 | }; 246 | 247 | export const camelCaseObjectKeys = (obj: Record, deep: boolean = false): Record => { 248 | if (!isPlainObject(obj)) return {}; 249 | if (isEmpty(obj)) return {}; 250 | const result: Record = {}; 251 | for (const key in obj) { 252 | if (obj.hasOwnProperty(key)) { 253 | const camelKey = camelCase(key); 254 | if (deep && isPlainObject(obj[key])) { 255 | result[camelKey] = camelCaseObjectKeys(obj[key] as Record, true); 256 | } else if (deep && Array.isArray(obj[key])) { 257 | result[camelKey] = (obj[key] as unknown[]).map((item) => 258 | isPlainObject(item) ? camelCaseObjectKeys(item as Record, true) : item, 259 | ); 260 | } else { 261 | result[camelKey] = obj[key]; 262 | } 263 | } 264 | } 265 | return result; 266 | } 267 | 268 | export const snakeCaseObjectKeys = (obj: Record, deep: boolean = false): Record => { 269 | if (!isPlainObject(obj)) return {}; 270 | if (isEmpty(obj)) return {}; 271 | const result: Record = {}; 272 | for (const key in obj) { 273 | if (obj.hasOwnProperty(key)) { 274 | const snakeKey = snakeCase(key); 275 | if (deep && isPlainObject(obj[key])) { 276 | result[snakeKey] = snakeCaseObjectKeys(obj[key] as Record, true); 277 | } else if (deep && Array.isArray(obj[key])) { 278 | result[snakeKey] = (obj[key] as unknown[]).map((item) => 279 | isPlainObject(item) ? snakeCaseObjectKeys(item as Record, true) : item, 280 | ); 281 | } else { 282 | result[snakeKey] = obj[key]; 283 | } 284 | } 285 | } 286 | return result; 287 | } 288 | -------------------------------------------------------------------------------- /src/fileUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * File utility functions 3 | */ 4 | 5 | export const formatFileSize = (bytes: number): string => { 6 | if (bytes === 0) return "0 Bytes"; 7 | 8 | const k = 1024; 9 | const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB"]; 10 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 11 | 12 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; 13 | }; 14 | 15 | export const getFileExtensionFromPath = (filename: string): string => { 16 | const lastDot = filename.lastIndexOf("."); 17 | return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : ""; 18 | }; 19 | 20 | export const getFileName = (filepath: string): string => { 21 | if (!filepath) return ""; 22 | const normalizedPath = filepath.replace(/\\/g, "/"); 23 | const parts = normalizedPath.split("/"); 24 | return parts[parts.length - 1] || ""; 25 | }; 26 | 27 | export const getFileNameWithoutExtension = (filename: string): string => { 28 | const lastDot = filename.lastIndexOf("."); 29 | return lastDot !== -1 ? filename.slice(0, lastDot) : filename; 30 | }; 31 | 32 | export const isImageFile = (filename: string): boolean => { 33 | const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff"]; 34 | const extension = getFileExtensionFromPath(filename); 35 | return imageExtensions.includes(extension); 36 | }; 37 | 38 | export const isVideoFile = (filename: string): boolean => { 39 | const videoExtensions = ["mp4", "avi", "mov", "wmv", "flv", "webm", "mkv", "m4v", "3gp"]; 40 | const extension = getFileExtensionFromPath(filename); 41 | return videoExtensions.includes(extension); 42 | }; 43 | 44 | export const isAudioFile = (filename: string): boolean => { 45 | const audioExtensions = ["mp3", "wav", "ogg", "aac", "flac", "m4a", "wma"]; 46 | const extension = getFileExtensionFromPath(filename); 47 | return audioExtensions.includes(extension); 48 | }; 49 | 50 | export const isDocumentFile = (filename: string): boolean => { 51 | const documentExtensions = ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf"]; 52 | const extension = getFileExtensionFromPath(filename); 53 | return documentExtensions.includes(extension); 54 | }; 55 | 56 | export const isArchiveFile = (filename: string): boolean => { 57 | const archiveExtensions = ["zip", "rar", "7z", "tar", "gz", "bz2", "xz"]; 58 | const extension = getFileExtensionFromPath(filename); 59 | return archiveExtensions.includes(extension); 60 | }; 61 | 62 | export const isCodeFile = (filename: string): boolean => { 63 | const codeExtensions = [ 64 | "js", 65 | "ts", 66 | "jsx", 67 | "tsx", 68 | "html", 69 | "css", 70 | "scss", 71 | "sass", 72 | "less", 73 | "py", 74 | "java", 75 | "c", 76 | "cpp", 77 | "h", 78 | "hpp", 79 | "cs", 80 | "php", 81 | "rb", 82 | "go", 83 | "rs", 84 | "swift", 85 | "kt", 86 | "dart", 87 | "vue", 88 | "svelte", 89 | "json", 90 | "xml", 91 | "yaml", 92 | "yml", 93 | ]; 94 | const extension = getFileExtensionFromPath(filename); 95 | return codeExtensions.includes(extension); 96 | }; 97 | 98 | export const getMimeType = (filename: string): string => { 99 | const extension = getFileExtensionFromPath(filename); 100 | 101 | const mimeTypes: Record = { 102 | // Images 103 | jpg: "image/jpeg", 104 | jpeg: "image/jpeg", 105 | png: "image/png", 106 | gif: "image/gif", 107 | webp: "image/webp", 108 | svg: "image/svg+xml", 109 | bmp: "image/bmp", 110 | ico: "image/x-icon", 111 | tiff: "image/tiff", 112 | 113 | // Videos 114 | mp4: "video/mp4", 115 | avi: "video/x-msvideo", 116 | mov: "video/quicktime", 117 | wmv: "video/x-ms-wmv", 118 | flv: "video/x-flv", 119 | webm: "video/webm", 120 | mkv: "video/x-matroska", 121 | 122 | // Audio 123 | mp3: "audio/mpeg", 124 | wav: "audio/wav", 125 | ogg: "audio/ogg", 126 | aac: "audio/aac", 127 | flac: "audio/flac", 128 | m4a: "audio/mp4", 129 | 130 | // Documents 131 | pdf: "application/pdf", 132 | doc: "application/msword", 133 | docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 134 | xls: "application/vnd.ms-excel", 135 | xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 136 | ppt: "application/vnd.ms-powerpoint", 137 | pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", 138 | txt: "text/plain", 139 | 140 | // Web 141 | html: "text/html", 142 | css: "text/css", 143 | js: "application/javascript", 144 | json: "application/json", 145 | xml: "application/xml", 146 | 147 | // Archives 148 | zip: "application/zip", 149 | rar: "application/x-rar-compressed", 150 | "7z": "application/x-7z-compressed", 151 | tar: "application/x-tar", 152 | gz: "application/gzip", 153 | }; 154 | 155 | return mimeTypes[extension] || "application/octet-stream"; 156 | }; 157 | 158 | export const downloadFile = (content: string | Blob, filename: string, mimeType?: string): void => { 159 | const blob = 160 | content instanceof Blob 161 | ? content 162 | : new Blob([content], { 163 | type: mimeType || getMimeType(filename), 164 | }); 165 | 166 | const url = URL.createObjectURL(blob); 167 | const link = document.createElement("a"); 168 | link.href = url; 169 | link.download = filename; 170 | document.body.appendChild(link); 171 | link.click(); 172 | document.body.removeChild(link); 173 | URL.revokeObjectURL(url); 174 | }; 175 | 176 | export const readFileAsText = (file: File): Promise => { 177 | return new Promise((resolve, reject) => { 178 | const reader = new FileReader(); 179 | reader.onload = () => resolve(reader.result as string); 180 | reader.onerror = () => reject(reader.error); 181 | reader.readAsText(file); 182 | }); 183 | }; 184 | 185 | export const readFileAsDataURL = (file: File): Promise => { 186 | return new Promise((resolve, reject) => { 187 | const reader = new FileReader(); 188 | reader.onload = () => resolve(reader.result as string); 189 | reader.onerror = () => reject(reader.error); 190 | reader.readAsDataURL(file); 191 | }); 192 | }; 193 | 194 | export const readFileAsArrayBuffer = (file: File): Promise => { 195 | return new Promise((resolve, reject) => { 196 | const reader = new FileReader(); 197 | reader.onload = () => resolve(reader.result as ArrayBuffer); 198 | reader.onerror = () => reject(reader.error); 199 | reader.readAsArrayBuffer(file); 200 | }); 201 | }; 202 | 203 | export const validateFileType = (file: File, allowedTypes: string[]): boolean => { 204 | const extension = getFileExtensionFromPath(file.name); 205 | return allowedTypes.map((type) => type.toLowerCase()).includes(extension); 206 | }; 207 | 208 | export const validateFileSize = (file: File, maxSizeInBytes: number): boolean => { 209 | return file.size <= maxSizeInBytes; 210 | }; 211 | 212 | export const generateUniqueFileName = (originalName: string): string => { 213 | const timestamp = Date.now(); 214 | const random = Math.random().toString(36).substring(2, 8); 215 | const lastDot = originalName.lastIndexOf("."); 216 | const extension = lastDot !== -1 ? originalName.slice(lastDot + 1) : ""; 217 | const nameWithoutExt = getFileNameWithoutExtension(originalName); 218 | 219 | return extension 220 | ? `${nameWithoutExt}_${timestamp}_${random}.${extension}` 221 | : `${nameWithoutExt}_${timestamp}_${random}`; 222 | }; 223 | 224 | export const sanitizeFileName = (filename: string): string => { 225 | // Remove or replace invalid characters 226 | return filename 227 | .replace(/[<>:"/\\|?*]/g, "_") 228 | .replace(/\s+/g, "_") 229 | .replace(/_{2,}/g, "_") 230 | .replace(/^_+|_+$/g, ""); 231 | }; 232 | 233 | export const parseCSV = (csvText: string, delimiter: string = ","): string[][] => { 234 | const lines = csvText.split("\n"); 235 | const result: string[][] = []; 236 | 237 | for (const line of lines) { 238 | if (line.trim() === "") continue; 239 | 240 | const row: string[] = []; 241 | let current = ""; 242 | let inQuotes = false; 243 | 244 | for (let i = 0; i < line.length; i++) { 245 | const char = line[i]; 246 | 247 | if (char === '"') { 248 | inQuotes = !inQuotes; 249 | } else if (char === delimiter && !inQuotes) { 250 | row.push(current.trim()); 251 | current = ""; 252 | } else { 253 | current += char; 254 | } 255 | } 256 | 257 | row.push(current.trim()); 258 | result.push(row); 259 | } 260 | 261 | return result; 262 | }; 263 | 264 | export const arrayToCSV = (data: any[][], delimiter: string = ","): string => { 265 | return data 266 | .map((row) => 267 | row 268 | .map((cell) => { 269 | const stringCell = String(cell); 270 | // Escape quotes and wrap in quotes if contains delimiter or quotes 271 | if (stringCell.includes(delimiter) || stringCell.includes('"') || stringCell.includes("\n")) { 272 | return `"${stringCell.replace(/"/g, '""')}"`; 273 | } 274 | return stringCell; 275 | }) 276 | .join(delimiter), 277 | ) 278 | .join("\n"); 279 | }; 280 | 281 | export const compressImage = ( 282 | file: File, 283 | quality: number = 0.8, 284 | maxWidth: number = 1920, 285 | maxHeight: number = 1080, 286 | ): Promise => { 287 | return new Promise((resolve, reject) => { 288 | const canvas = document.createElement("canvas"); 289 | const ctx = canvas.getContext("2d"); 290 | const img = new Image(); 291 | 292 | img.onload = () => { 293 | // Calculate new dimensions 294 | let { width, height } = img; 295 | 296 | if (width > maxWidth) { 297 | height = (height * maxWidth) / width; 298 | width = maxWidth; 299 | } 300 | 301 | if (height > maxHeight) { 302 | width = (width * maxHeight) / height; 303 | height = maxHeight; 304 | } 305 | 306 | canvas.width = width; 307 | canvas.height = height; 308 | 309 | // Draw and compress 310 | ctx?.drawImage(img, 0, 0, width, height); 311 | canvas.toBlob( 312 | (blob) => { 313 | if (blob) { 314 | resolve(blob); 315 | } else { 316 | reject(new Error("Failed to compress image")); 317 | } 318 | }, 319 | "image/jpeg", 320 | quality, 321 | ); 322 | }; 323 | 324 | img.onerror = () => reject(new Error("Failed to load image")); 325 | img.src = URL.createObjectURL(file); 326 | }); 327 | }; 328 | -------------------------------------------------------------------------------- /src/colorUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Color utility functions 3 | */ 4 | 5 | export interface RGB { 6 | r: number; 7 | g: number; 8 | b: number; 9 | } 10 | 11 | export interface HSL { 12 | h: number; 13 | s: number; 14 | l: number; 15 | } 16 | 17 | export interface HSV { 18 | h: number; 19 | s: number; 20 | v: number; 21 | } 22 | 23 | export const hexToRgb = (hex: string): RGB | null => { 24 | // Handle 6-digit hex 25 | const longResult = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 26 | if (longResult) { 27 | return { 28 | r: parseInt(longResult[1], 16), 29 | g: parseInt(longResult[2], 16), 30 | b: parseInt(longResult[3], 16), 31 | }; 32 | } 33 | 34 | // Handle 3-digit hex 35 | const shortResult = /^#?([a-f\d])([a-f\d])([a-f\d])$/i.exec(hex); 36 | if (shortResult) { 37 | return { 38 | r: parseInt(shortResult[1] + shortResult[1], 16), 39 | g: parseInt(shortResult[2] + shortResult[2], 16), 40 | b: parseInt(shortResult[3] + shortResult[3], 16), 41 | }; 42 | } 43 | 44 | return null; 45 | }; 46 | 47 | export const rgbToHex = (r: number, g: number, b: number): string => { 48 | const toHex = (n: number): string => { 49 | const clamped = Math.max(0, Math.min(255, Math.round(n))); 50 | const hex = clamped.toString(16); 51 | return hex.length === 1 ? "0" + hex : hex; 52 | }; 53 | 54 | return `#${toHex(r)}${toHex(g)}${toHex(b)}`; 55 | }; 56 | 57 | export const rgbToHsl = (r: number, g: number, b: number): HSL => { 58 | r /= 255; 59 | g /= 255; 60 | b /= 255; 61 | 62 | const max = Math.max(r, g, b); 63 | const min = Math.min(r, g, b); 64 | let h: number, s: number; 65 | const l = (max + min) / 2; 66 | 67 | if (max === min) { 68 | h = s = 0; // achromatic 69 | } else { 70 | const d = max - min; 71 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 72 | 73 | switch (max) { 74 | case r: 75 | h = (g - b) / d + (g < b ? 6 : 0); 76 | break; 77 | case g: 78 | h = (b - r) / d + 2; 79 | break; 80 | case b: 81 | h = (r - g) / d + 4; 82 | break; 83 | default: 84 | h = 0; 85 | } 86 | h /= 6; 87 | } 88 | 89 | return { 90 | h: Math.round(h * 360), 91 | s: Math.round(s * 100), 92 | l: Math.round(l * 100), 93 | }; 94 | }; 95 | 96 | export const hslToRgb = (h: number, s: number, l: number): RGB => { 97 | h /= 360; 98 | s /= 100; 99 | l /= 100; 100 | 101 | const hue2rgb = (p: number, q: number, t: number): number => { 102 | if (t < 0) t += 1; 103 | if (t > 1) t -= 1; 104 | if (t < 1 / 6) return p + (q - p) * 6 * t; 105 | if (t < 1 / 2) return q; 106 | if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; 107 | return p; 108 | }; 109 | 110 | let r: number, g: number, b: number; 111 | 112 | if (s === 0) { 113 | r = g = b = l; // achromatic 114 | } else { 115 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 116 | const p = 2 * l - q; 117 | r = hue2rgb(p, q, h + 1 / 3); 118 | g = hue2rgb(p, q, h); 119 | b = hue2rgb(p, q, h - 1 / 3); 120 | } 121 | 122 | return { 123 | r: Math.round(r * 255), 124 | g: Math.round(g * 255), 125 | b: Math.round(b * 255), 126 | }; 127 | }; 128 | 129 | export const rgbToHsv = (r: number, g: number, b: number): HSV => { 130 | r /= 255; 131 | g /= 255; 132 | b /= 255; 133 | 134 | const max = Math.max(r, g, b); 135 | const min = Math.min(r, g, b); 136 | const diff = max - min; 137 | 138 | let h: number; 139 | const s = max === 0 ? 0 : diff / max; 140 | const v = max; 141 | 142 | if (diff === 0) { 143 | h = 0; 144 | } else { 145 | switch (max) { 146 | case r: 147 | h = ((g - b) / diff + (g < b ? 6 : 0)) / 6; 148 | break; 149 | case g: 150 | h = ((b - r) / diff + 2) / 6; 151 | break; 152 | case b: 153 | h = ((r - g) / diff + 4) / 6; 154 | break; 155 | default: 156 | h = 0; 157 | } 158 | } 159 | 160 | return { 161 | h: Math.round(h * 360), 162 | s: Math.round(s * 100), 163 | v: Math.round(v * 100), 164 | }; 165 | }; 166 | 167 | export const hsvToRgb = (h: number, s: number, v: number): RGB => { 168 | h /= 360; 169 | s /= 100; 170 | v /= 100; 171 | 172 | const c = v * s; 173 | const x = c * (1 - Math.abs(((h * 6) % 2) - 1)); 174 | const m = v - c; 175 | 176 | let r: number, g: number, b: number; 177 | 178 | if (h < 1 / 6) { 179 | r = c; 180 | g = x; 181 | b = 0; 182 | } else if (h < 2 / 6) { 183 | r = x; 184 | g = c; 185 | b = 0; 186 | } else if (h < 3 / 6) { 187 | r = 0; 188 | g = c; 189 | b = x; 190 | } else if (h < 4 / 6) { 191 | r = 0; 192 | g = x; 193 | b = c; 194 | } else if (h < 5 / 6) { 195 | r = x; 196 | g = 0; 197 | b = c; 198 | } else { 199 | r = c; 200 | g = 0; 201 | b = x; 202 | } 203 | 204 | return { 205 | r: Math.round((r + m) * 255), 206 | g: Math.round((g + m) * 255), 207 | b: Math.round((b + m) * 255), 208 | }; 209 | }; 210 | 211 | export const lighten = (hex: string, amount: number): string => { 212 | const rgb = hexToRgb(hex); 213 | if (!rgb) return hex; 214 | 215 | const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); 216 | hsl.l = Math.min(100, hsl.l + amount); 217 | 218 | const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); 219 | return rgbToHex(newRgb.r, newRgb.g, newRgb.b); 220 | }; 221 | 222 | export const darken = (hex: string, amount: number): string => { 223 | const rgb = hexToRgb(hex); 224 | if (!rgb) return hex; 225 | 226 | const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); 227 | hsl.l = Math.max(0, hsl.l - amount); 228 | 229 | const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); 230 | return rgbToHex(newRgb.r, newRgb.g, newRgb.b); 231 | }; 232 | 233 | export const saturate = (hex: string, amount: number): string => { 234 | const rgb = hexToRgb(hex); 235 | if (!rgb) return hex; 236 | 237 | const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); 238 | hsl.s = Math.min(100, hsl.s + amount); 239 | 240 | const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); 241 | return rgbToHex(newRgb.r, newRgb.g, newRgb.b); 242 | }; 243 | 244 | export const desaturate = (hex: string, amount: number): string => { 245 | const rgb = hexToRgb(hex); 246 | if (!rgb) return hex; 247 | 248 | const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); 249 | hsl.s = Math.max(0, hsl.s - amount); 250 | 251 | const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); 252 | return rgbToHex(newRgb.r, newRgb.g, newRgb.b); 253 | }; 254 | 255 | export const complement = (hex: string): string => { 256 | const rgb = hexToRgb(hex); 257 | if (!rgb) return hex; 258 | 259 | const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); 260 | hsl.h = (hsl.h + 180) % 360; 261 | 262 | const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); 263 | return rgbToHex(newRgb.r, newRgb.g, newRgb.b); 264 | }; 265 | 266 | export const analogous = (hex: string, angle: number = 30): string[] => { 267 | const rgb = hexToRgb(hex); 268 | if (!rgb) return [hex]; 269 | 270 | const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); 271 | 272 | const color1 = hslToRgb((hsl.h + angle) % 360, hsl.s, hsl.l); 273 | const color2 = hslToRgb((hsl.h - angle + 360) % 360, hsl.s, hsl.l); 274 | 275 | const colors = [hex, rgbToHex(color1.r, color1.g, color1.b), rgbToHex(color2.r, color2.g, color2.b)]; 276 | 277 | return colors; 278 | }; 279 | 280 | export const triadic = (hex: string): string[] => { 281 | const rgb = hexToRgb(hex); 282 | if (!rgb) return [hex]; 283 | 284 | const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); 285 | 286 | const color1 = hslToRgb((hsl.h + 120) % 360, hsl.s, hsl.l); 287 | const color2 = hslToRgb((hsl.h + 240) % 360, hsl.s, hsl.l); 288 | 289 | const colors = [hex, rgbToHex(color1.r, color1.g, color1.b), rgbToHex(color2.r, color2.g, color2.b)]; 290 | 291 | return colors; 292 | }; 293 | 294 | export const tetradic = (hex: string): string[] => { 295 | const rgb = hexToRgb(hex); 296 | if (!rgb) return [hex]; 297 | 298 | const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); 299 | 300 | const color1 = hslToRgb((hsl.h + 90) % 360, hsl.s, hsl.l); 301 | const color2 = hslToRgb((hsl.h + 180) % 360, hsl.s, hsl.l); 302 | const color3 = hslToRgb((hsl.h + 270) % 360, hsl.s, hsl.l); 303 | 304 | const colors = [ 305 | hex, 306 | rgbToHex(color1.r, color1.g, color1.b), 307 | rgbToHex(color2.r, color2.g, color2.b), 308 | rgbToHex(color3.r, color3.g, color3.b), 309 | ]; 310 | 311 | return colors; 312 | }; 313 | 314 | export const monochromatic = (hex: string, steps: number = 5): string[] => { 315 | const rgb = hexToRgb(hex); 316 | if (!rgb) return [hex]; 317 | 318 | const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); 319 | const colors: string[] = []; 320 | 321 | for (let i = 0; i < steps; i++) { 322 | const lightness = Math.max(0, Math.min(100, hsl.l + (i - Math.floor(steps / 2)) * 20)); 323 | const newRgb = hslToRgb(hsl.h, hsl.s, lightness); 324 | colors.push(rgbToHex(newRgb.r, newRgb.g, newRgb.b)); 325 | } 326 | 327 | return colors; 328 | }; 329 | 330 | export const getContrast = (hex1: string, hex2: string): number => { 331 | const getLuminance = (hex: string): number => { 332 | const rgb = hexToRgb(hex); 333 | if (!rgb) return 0; 334 | 335 | const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((c) => { 336 | c /= 255; 337 | return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); 338 | }); 339 | 340 | return 0.2126 * r + 0.7152 * g + 0.0722 * b; 341 | }; 342 | 343 | const lum1 = getLuminance(hex1); 344 | const lum2 = getLuminance(hex2); 345 | const brightest = Math.max(lum1, lum2); 346 | const darkest = Math.min(lum1, lum2); 347 | 348 | return (brightest + 0.05) / (darkest + 0.05); 349 | }; 350 | 351 | export const isLight = (hex: string): boolean => { 352 | const rgb = hexToRgb(hex); 353 | if (!rgb) return false; 354 | 355 | const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000; 356 | return brightness > 128; 357 | }; 358 | 359 | export const isDark = (hex: string): boolean => { 360 | return !isLight(hex); 361 | }; 362 | 363 | export const randomColor = (): string => { 364 | const r = Math.floor(Math.random() * 256); 365 | const g = Math.floor(Math.random() * 256); 366 | const b = Math.floor(Math.random() * 256); 367 | return rgbToHex(r, g, b); 368 | }; 369 | 370 | export const rgbString = (r: number, g: number, b: number, a?: number): string => { 371 | if (a !== undefined) { 372 | return `rgba(${r}, ${g}, ${b}, ${a})`; 373 | } 374 | return `rgb(${r}, ${g}, ${b})`; 375 | }; 376 | 377 | export const hslString = (h: number, s: number, l: number, a?: number): string => { 378 | if (a !== undefined) { 379 | return `hsla(${h}, ${s}%, ${l}%, ${a})`; 380 | } 381 | return `hsl(${h}, ${s}%, ${l}%)`; 382 | }; 383 | -------------------------------------------------------------------------------- /src/imageUtils.ts: -------------------------------------------------------------------------------- 1 | // DOM-based utilities (existing) 2 | export const base64ToBlob = (base64: string, contentType: string): Blob => { 3 | const byteCharacters = atob(base64.split(",")[1]); 4 | const byteNumbers = new Array(byteCharacters.length).fill(0).map((_, i) => byteCharacters.charCodeAt(i)); 5 | const byteArray = new Uint8Array(byteNumbers); 6 | return new Blob([byteArray], { type: contentType }); 7 | }; 8 | 9 | export const blobToBase64 = (blob: Blob): Promise => { 10 | return new Promise((resolve, reject) => { 11 | const reader = new FileReader(); 12 | reader.onloadend = () => resolve(reader.result as string); 13 | reader.onerror = reject; 14 | reader.readAsDataURL(blob); 15 | }); 16 | }; 17 | 18 | export const resizeImage = (base64: string, width: number, height: number): Promise => { 19 | return new Promise((resolve, reject) => { 20 | const img = new Image(); 21 | img.src = base64; 22 | img.onload = () => { 23 | const canvas = document.createElement("canvas"); 24 | canvas.width = width; 25 | canvas.height = height; 26 | const ctx = canvas.getContext("2d"); 27 | if (ctx) { 28 | ctx.drawImage(img, 0, 0, width, height); 29 | resolve(canvas.toDataURL()); 30 | } else { 31 | reject(new Error("Could not get canvas context")); 32 | } 33 | }; 34 | img.onerror = reject; 35 | }); 36 | }; 37 | 38 | export const imageToGrayScale = (base64: string): Promise => { 39 | return new Promise((resolve, reject) => { 40 | const img = new Image(); 41 | img.src = base64; 42 | img.onload = () => { 43 | const canvas = document.createElement("canvas"); 44 | canvas.width = img.width; 45 | canvas.height = img.height; 46 | const ctx = canvas.getContext("2d"); 47 | if (ctx) { 48 | ctx.drawImage(img, 0, 0); 49 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); 50 | for (let i = 0; i < imageData.data.length; i += 4) { 51 | const avg = (imageData.data[i] + imageData.data[i + 1] + imageData.data[i + 2]) / 3; 52 | imageData.data[i] = avg; 53 | imageData.data[i + 1] = avg; 54 | imageData.data[i + 2] = avg; 55 | } 56 | ctx.putImageData(imageData, 0, 0); 57 | resolve(canvas.toDataURL()); 58 | } else { 59 | reject(new Error("Could not get canvas context")); 60 | } 61 | }; 62 | img.onerror = reject; 63 | }); 64 | }; 65 | 66 | // ===== NEW: DOM-independent utilities (easily testable) ===== 67 | 68 | // Image format validation 69 | export const isValidImageFormat = (format: string): boolean => { 70 | const validFormats = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "tif"]; 71 | return validFormats.includes(format.toLowerCase()); 72 | }; 73 | 74 | export const getImageFormatFromExtension = (filename: string): string | null => { 75 | const extension = filename.split(".").pop()?.toLowerCase(); 76 | return extension && isValidImageFormat(extension) ? extension : null; 77 | }; 78 | 79 | export const getImageFormatFromMimeType = (mimeType: string): string | null => { 80 | const mimeToFormat: Record = { 81 | "image/jpeg": "jpg", 82 | "image/png": "png", 83 | "image/gif": "gif", 84 | "image/webp": "webp", 85 | "image/svg+xml": "svg", 86 | "image/bmp": "bmp", 87 | "image/x-icon": "ico", 88 | "image/tiff": "tiff", 89 | }; 90 | return mimeToFormat[mimeType.toLowerCase()] || null; 91 | }; 92 | 93 | export const getMimeTypeFromFormat = (format: string): string | null => { 94 | const formatToMime: Record = { 95 | jpg: "image/jpeg", 96 | jpeg: "image/jpeg", 97 | png: "image/png", 98 | gif: "image/gif", 99 | webp: "image/webp", 100 | svg: "image/svg+xml", 101 | bmp: "image/bmp", 102 | ico: "image/x-icon", 103 | tiff: "image/tiff", 104 | tif: "image/tiff", 105 | }; 106 | return formatToMime[format.toLowerCase()] || null; 107 | }; 108 | 109 | export const extractImageNameFromUrl = (url: string): string | null => { 110 | try { 111 | const urlObj = new URL(url); 112 | const pathname = urlObj.pathname; 113 | const segments = pathname.split("/"); 114 | const lastSegment = segments[segments.length - 1]; 115 | 116 | // Check if the file has an image extension 117 | const hasImageExtension = 118 | lastSegment && /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico|tiff|tif)(\?.*)?$/i.test(lastSegment); 119 | 120 | return hasImageExtension ? lastSegment.split("?")[0] : null; 121 | } catch { 122 | return null; 123 | } 124 | }; 125 | 126 | // Base64 utilities 127 | export const isBase64Image = (base64: string): boolean => { 128 | const base64Regex = /^data:image\/(jpeg|jpg|png|gif|webp|svg\+xml|bmp|x-icon|tiff);base64,/i; 129 | return base64Regex.test(base64); 130 | }; 131 | 132 | export const getBase64ImageFormat = (base64: string): string | null => { 133 | const match = base64.match(/^data:image\/([^;]+);base64,/i); 134 | return match ? match[1] : null; 135 | }; 136 | 137 | export const getBase64ImageSize = (base64: string): number => { 138 | if (!isBase64Image(base64)) return 0; 139 | const base64Data = base64.split(",")[1]; 140 | return Math.round((base64Data.length * 3) / 4); 141 | }; 142 | 143 | export const stripBase64Header = (base64: string): string => { 144 | return base64.includes(",") ? base64.split(",")[1] : base64; 145 | }; 146 | 147 | export const addBase64Header = (base64Data: string, format: string): string => { 148 | const mimeType = getMimeTypeFromFormat(format); 149 | return mimeType ? `data:${mimeType};base64,${base64Data}` : base64Data; 150 | }; 151 | 152 | // Image dimension utilities 153 | export const calculateAspectRatio = (width: number, height: number): number => { 154 | return width / height; 155 | }; 156 | 157 | export const calculateDimensionsFromAspectRatio = ( 158 | originalWidth: number, 159 | originalHeight: number, 160 | targetWidth?: number, 161 | targetHeight?: number, 162 | ): { width: number; height: number } => { 163 | const aspectRatio = calculateAspectRatio(originalWidth, originalHeight); 164 | 165 | if (targetWidth && !targetHeight) { 166 | return { 167 | width: targetWidth, 168 | height: Math.round(targetWidth / aspectRatio), 169 | }; 170 | } 171 | 172 | if (targetHeight && !targetWidth) { 173 | return { 174 | width: Math.round(targetHeight * aspectRatio), 175 | height: targetHeight, 176 | }; 177 | } 178 | 179 | if (targetWidth && targetHeight) { 180 | return { width: targetWidth, height: targetHeight }; 181 | } 182 | 183 | return { width: originalWidth, height: originalHeight }; 184 | }; 185 | 186 | export const isSquareImage = (width: number, height: number): boolean => { 187 | return width === height; 188 | }; 189 | 190 | export const isPortraitImage = (width: number, height: number): boolean => { 191 | return height > width; 192 | }; 193 | 194 | export const isLandscapeImage = (width: number, height: number): boolean => { 195 | return width > height; 196 | }; 197 | 198 | export const rgbToGrayscale = (r: number, g: number, b: number): number => { 199 | return Math.round(0.299 * r + 0.587 * g + 0.114 * b); 200 | }; 201 | 202 | export const adjustBrightness = ( 203 | r: number, 204 | g: number, 205 | b: number, 206 | factor: number, 207 | ): { r: number; g: number; b: number } => { 208 | return { 209 | r: Math.max(0, Math.min(255, Math.round(r * factor))), 210 | g: Math.max(0, Math.min(255, Math.round(g * factor))), 211 | b: Math.max(0, Math.min(255, Math.round(b * factor))), 212 | }; 213 | }; 214 | 215 | // File size utilities 216 | export const formatImageFileSize = (bytes: number): string => { 217 | if (bytes === 0) return "0 Bytes"; 218 | const k = 1024; 219 | const sizes = ["Bytes", "KB", "MB", "GB"]; 220 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 221 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; 222 | }; 223 | 224 | export const convertBytesToKB = (bytes: number): number => { 225 | return Math.round((bytes / 1024) * 100) / 100; 226 | }; 227 | 228 | export const convertBytesToMB = (bytes: number): number => { 229 | return Math.round((bytes / (1024 * 1024)) * 100) / 100; 230 | }; 231 | 232 | // Image quality assessment 233 | export const estimateImageQuality = (fileSize: number, width: number, height: number): "low" | "medium" | "high" => { 234 | const pixelCount = width * height; 235 | const bytesPerPixel = fileSize / pixelCount; 236 | 237 | if (bytesPerPixel < 0.5) return "low"; 238 | if (bytesPerPixel < 2) return "medium"; 239 | return "high"; 240 | }; 241 | 242 | export const getImageSizeCategory = ( 243 | width: number, 244 | height: number, 245 | ): "thumbnail" | "small" | "medium" | "large" | "extra-large" => { 246 | const maxDimension = Math.max(width, height); 247 | 248 | if (maxDimension <= 150) return "thumbnail"; 249 | if (maxDimension <= 400) return "small"; 250 | if (maxDimension <= 800) return "medium"; 251 | if (maxDimension <= 1920) return "large"; 252 | return "extra-large"; 253 | }; 254 | 255 | // Image transformation utilities 256 | export const flipImageData = ( 257 | imageData: number[], 258 | width: number, 259 | height: number, 260 | direction: "horizontal" | "vertical", 261 | ): number[] => { 262 | const result = new Array(imageData.length); 263 | const channels = 4; // RGBA 264 | 265 | for (let y = 0; y < height; y++) { 266 | for (let x = 0; x < width; x++) { 267 | const sourceIndex = (y * width + x) * channels; 268 | let targetIndex: number; 269 | 270 | if (direction === "horizontal") { 271 | targetIndex = (y * width + (width - 1 - x)) * channels; 272 | } else { 273 | targetIndex = ((height - 1 - y) * width + x) * channels; 274 | } 275 | 276 | for (let c = 0; c < channels; c++) { 277 | result[targetIndex + c] = imageData[sourceIndex + c]; 278 | } 279 | } 280 | } 281 | 282 | return result; 283 | }; 284 | 285 | export const rotateImageData90 = ( 286 | imageData: number[], 287 | width: number, 288 | height: number, 289 | ): { data: number[]; width: number; height: number } => { 290 | const result = new Array(imageData.length); 291 | const channels = 4; // RGBA 292 | 293 | for (let y = 0; y < height; y++) { 294 | for (let x = 0; x < width; x++) { 295 | const sourceIndex = (y * width + x) * channels; 296 | const targetIndex = (x * height + (height - 1 - y)) * channels; 297 | 298 | for (let c = 0; c < channels; c++) { 299 | result[targetIndex + c] = imageData[sourceIndex + c]; 300 | } 301 | } 302 | } 303 | 304 | return { 305 | data: result, 306 | width: height, 307 | height: width, 308 | }; 309 | }; 310 | -------------------------------------------------------------------------------- /test/arrayUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | groupByKey, 3 | countByKey, 4 | sumBy, 5 | averageBy, 6 | uniqueBy, 7 | intersectionBy, 8 | differenceBy, 9 | unionBy, 10 | partition, 11 | sample, 12 | sampleSize, 13 | takeWhile, 14 | dropWhile, 15 | findIndex, 16 | findLastIndex, 17 | first, 18 | last, 19 | nth, 20 | pull, 21 | pullAt, 22 | zip, 23 | unzip, 24 | zipWith, 25 | xor, 26 | move, 27 | transpose, 28 | isSubset, 29 | isSuperset, 30 | frequency, 31 | mostFrequent, 32 | leastFrequent, 33 | } from "../src/arrayUtils"; 34 | import { describe, expect } from "@jest/globals"; 35 | 36 | describe("ArrayUtils", () => { 37 | const testData = [ 38 | { id: 1, name: "Alice", age: 25, category: "A" }, 39 | { id: 2, name: "Bob", age: 30, category: "B" }, 40 | { id: 3, name: "Charlie", age: 25, category: "A" }, 41 | { id: 4, name: "David", age: 35, category: "B" }, 42 | ]; 43 | 44 | describe("groupByKey", () => { 45 | it("should group array by specified key", () => { 46 | const result = groupByKey(testData, "category"); 47 | expect(result).toEqual({ 48 | A: [testData[0], testData[2]], 49 | B: [testData[1], testData[3]], 50 | }); 51 | }); 52 | }); 53 | 54 | describe("countByKey", () => { 55 | it("should count occurrences by key", () => { 56 | const result = countByKey(testData, "category"); 57 | expect(result).toEqual({ A: 2, B: 2 }); 58 | }); 59 | }); 60 | 61 | describe("sumBy", () => { 62 | it("should sum values by key", () => { 63 | const result = sumBy(testData, "age"); 64 | expect(result).toBe(115); 65 | }); 66 | }); 67 | 68 | describe("averageBy", () => { 69 | it("should calculate average by key", () => { 70 | const result = averageBy(testData, "age"); 71 | expect(result).toBe(28.75); 72 | }); 73 | 74 | it("should return 0 for empty array", () => { 75 | expect(averageBy([], "age")).toBe(0); 76 | }); 77 | }); 78 | 79 | describe("uniqueBy", () => { 80 | it("should return unique items by key", () => { 81 | const result = uniqueBy(testData, "age"); 82 | expect(result).toHaveLength(3); 83 | expect(result.map((item) => item.age)).toEqual([25, 30, 35]); 84 | }); 85 | }); 86 | 87 | describe("intersectionBy", () => { 88 | it("should find intersection by key", () => { 89 | const array1 = [{ id: 1 }, { id: 2 }, { id: 3 }]; 90 | const array2 = [{ id: 2 }, { id: 3 }, { id: 4 }]; 91 | const result = intersectionBy(array1, array2, "id"); 92 | expect(result).toHaveLength(2); 93 | expect(result.map((item) => item.id)).toEqual([2, 3]); 94 | }); 95 | }); 96 | 97 | describe("differenceBy", () => { 98 | it("should find difference by key", () => { 99 | const array1 = [{ id: 1 }, { id: 2 }, { id: 3 }]; 100 | const array2 = [{ id: 2 }, { id: 3 }, { id: 4 }]; 101 | const result = differenceBy(array1, array2, "id"); 102 | expect(result).toHaveLength(1); 103 | expect(result[0].id).toBe(1); 104 | }); 105 | }); 106 | 107 | describe("unionBy", () => { 108 | it("should create union by key", () => { 109 | const array1 = [{ id: 1 }, { id: 2 }]; 110 | const array2 = [{ id: 2 }, { id: 3 }]; 111 | const result = unionBy(array1, array2, "id"); 112 | expect(result).toHaveLength(3); 113 | expect(result.map((item) => item.id)).toEqual([1, 2, 3]); 114 | }); 115 | }); 116 | 117 | describe("partition", () => { 118 | it("should partition array based on predicate", () => { 119 | const [evens, odds] = partition([1, 2, 3, 4, 5], (n) => n % 2 === 0); 120 | expect(evens).toEqual([2, 4]); 121 | expect(odds).toEqual([1, 3, 5]); 122 | }); 123 | }); 124 | 125 | describe("sample", () => { 126 | it("should return random element from array", () => { 127 | const result = sample([1, 2, 3, 4, 5]); 128 | expect([1, 2, 3, 4, 5]).toContain(result); 129 | }); 130 | 131 | it("should return undefined for empty array", () => { 132 | expect(sample([])).toBeUndefined(); 133 | }); 134 | }); 135 | 136 | describe("sampleSize", () => { 137 | it("should return specified number of random elements", () => { 138 | const arr = [1, 2, 3, 4, 5]; 139 | const result = sampleSize(arr, 3); 140 | expect(result).toHaveLength(3); 141 | result.forEach((item) => expect(arr).toContain(item)); 142 | }); 143 | 144 | it("should return entire array if n is larger than array length", () => { 145 | const arr = [1, 2, 3]; 146 | const result = sampleSize(arr, 5); 147 | expect(result).toHaveLength(3); 148 | }); 149 | }); 150 | 151 | describe("takeWhile", () => { 152 | it("should take elements while predicate is true", () => { 153 | const result = takeWhile([1, 2, 3, 4, 5], (n) => n < 4); 154 | expect(result).toEqual([1, 2, 3]); 155 | }); 156 | }); 157 | 158 | describe("dropWhile", () => { 159 | it("should drop elements while predicate is true", () => { 160 | const result = dropWhile([1, 2, 3, 4, 5], (n) => n < 4); 161 | expect(result).toEqual([4, 5]); 162 | }); 163 | }); 164 | 165 | describe("findIndex", () => { 166 | it("should find index of element that matches predicate", () => { 167 | expect(findIndex([1, 2, 3, 4], (x) => x > 2)).toBe(2); 168 | expect(findIndex([1, 2, 3], (x) => x === 2)).toBe(1); 169 | }); 170 | 171 | it("should return -1 when no element matches", () => { 172 | expect(findIndex([1, 2, 3], (x) => x > 10)).toBe(-1); // Line 122 coverage 173 | expect(findIndex([], (x) => x > 0)).toBe(-1); 174 | }); 175 | }); 176 | 177 | describe("findLastIndex", () => { 178 | it("should find last index of element that matches predicate", () => { 179 | expect(findLastIndex([1, 2, 3, 2, 4], (x) => x === 2)).toBe(3); 180 | expect(findLastIndex([1, 2, 3, 4], (x) => x > 2)).toBe(3); 181 | }); 182 | 183 | it("should return -1 when no element matches", () => { 184 | expect(findLastIndex([1, 2, 3], (x) => x > 10)).toBe(-1); // Line 131 coverage 185 | expect(findLastIndex([], (x) => x > 0)).toBe(-1); 186 | }); 187 | }); 188 | 189 | describe("first", () => { 190 | it("should return first element", () => { 191 | expect(first([1, 2, 3])).toBe(1); 192 | expect(first([])).toBeUndefined(); 193 | }); 194 | }); 195 | 196 | describe("last", () => { 197 | it("should return last element", () => { 198 | expect(last([1, 2, 3])).toBe(3); 199 | expect(last([])).toBeUndefined(); 200 | }); 201 | }); 202 | 203 | describe("nth", () => { 204 | it("should return element at specified index", () => { 205 | const arr = [1, 2, 3, 4, 5]; 206 | expect(nth(arr, 2)).toBe(3); 207 | expect(nth(arr, -1)).toBe(5); 208 | expect(nth(arr, 10)).toBeUndefined(); 209 | }); 210 | }); 211 | 212 | describe("pull", () => { 213 | it("should remove specified values", () => { 214 | const result = pull([1, 2, 3, 4, 5], 2, 4); 215 | expect(result).toEqual([1, 3, 5]); 216 | }); 217 | }); 218 | 219 | describe("pullAt", () => { 220 | it("should remove and return elements at specified indexes", () => { 221 | const arr = [1, 2, 3, 4, 5]; 222 | const result = pullAt(arr, [1, 3]); 223 | expect(result).toEqual([4, 2]); // elementos removidos en orden correcto 224 | expect(arr).toEqual([1, 3, 5]); // array restante 225 | }); 226 | }); 227 | 228 | describe("zip", () => { 229 | it("should zip two arrays", () => { 230 | const result = zip([1, 2, 3], ["a", "b", "c"]); 231 | expect(result).toEqual([ 232 | [1, "a"], 233 | [2, "b"], 234 | [3, "c"], 235 | ]); 236 | }); 237 | 238 | it("should handle arrays of different lengths", () => { 239 | const result = zip([1, 2], ["a", "b", "c"]); 240 | expect(result).toEqual([ 241 | [1, "a"], 242 | [2, "b"], 243 | ]); 244 | }); 245 | }); 246 | 247 | describe("unzip", () => { 248 | it("should unzip array of pairs", () => { 249 | const [first, second] = unzip([ 250 | [1, "a"], 251 | [2, "b"], 252 | [3, "c"], 253 | ]); 254 | expect(first).toEqual([1, 2, 3]); 255 | expect(second).toEqual(["a", "b", "c"]); 256 | }); 257 | }); 258 | 259 | describe("zipWith", () => { 260 | it("should zip with custom function", () => { 261 | const result = zipWith([1, 2, 3], [4, 5, 6], (a, b) => a + b); 262 | expect(result).toEqual([5, 7, 9]); 263 | }); 264 | }); 265 | 266 | describe("xor", () => { 267 | it("should return symmetric difference", () => { 268 | const result = xor([1, 2, 3], [3, 4, 5]); 269 | expect(result.sort()).toEqual([1, 2, 4, 5]); 270 | }); 271 | }); 272 | 273 | describe("move", () => { 274 | it("should move element to different position", () => { 275 | const result = move([1, 2, 3, 4, 5], 1, 3); 276 | expect(result).toEqual([1, 3, 4, 2, 5]); 277 | }); 278 | }); 279 | 280 | describe("transpose", () => { 281 | it("should transpose matrix", () => { 282 | const matrix = [ 283 | [1, 2, 3], 284 | [4, 5, 6], 285 | ]; 286 | const result = transpose(matrix); 287 | expect(result).toEqual([ 288 | [1, 4], 289 | [2, 5], 290 | [3, 6], 291 | ]); 292 | }); 293 | 294 | it("should handle empty matrix", () => { 295 | expect(transpose([])).toEqual([]); 296 | }); 297 | }); 298 | 299 | describe("isSubset", () => { 300 | it("should check if array is subset", () => { 301 | expect(isSubset([1, 2], [1, 2, 3, 4])).toBe(true); 302 | expect(isSubset([1, 5], [1, 2, 3, 4])).toBe(false); 303 | }); 304 | }); 305 | 306 | describe("isSuperset", () => { 307 | it("should check if array is superset", () => { 308 | expect(isSuperset([1, 2, 3, 4], [1, 2])).toBe(true); 309 | expect(isSuperset([1, 2, 3], [1, 5])).toBe(false); 310 | }); 311 | }); 312 | 313 | describe("frequency", () => { 314 | it("should return frequency map", () => { 315 | const result = frequency([1, 2, 2, 3, 3, 3]); 316 | expect(result.get(1)).toBe(1); 317 | expect(result.get(2)).toBe(2); 318 | expect(result.get(3)).toBe(3); 319 | }); 320 | }); 321 | 322 | describe("mostFrequent", () => { 323 | it("should return most frequent element", () => { 324 | const result = mostFrequent([1, 2, 2, 3, 3, 3]); 325 | expect(result).toBe(3); 326 | }); 327 | 328 | it("should return undefined for empty array", () => { 329 | expect(mostFrequent([])).toBeUndefined(); 330 | }); 331 | }); 332 | 333 | describe("leastFrequent", () => { 334 | it("should return least frequent element", () => { 335 | const result = leastFrequent([1, 2, 2, 3, 3, 3]); 336 | expect(result).toBe(1); 337 | }); 338 | 339 | it("should return undefined for empty array", () => { 340 | expect(leastFrequent([])).toBeUndefined(); 341 | }); 342 | }); 343 | }); 344 | -------------------------------------------------------------------------------- /test/sort.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | sortArray, 3 | sortByMultipleKeys, 4 | sortByCustomComparator, 5 | minBy, 6 | maxBy, 7 | shuffleArray, 8 | reverseArray, 9 | rotateArray, 10 | chunkArray, 11 | flattenArray, 12 | compactArray, 13 | removeDuplicates, 14 | removeFalsyValues, 15 | removeFalsyAndDuplicates, 16 | removeItem, 17 | removeItems, 18 | removeItemByIndex, 19 | removeItemsByIndex, 20 | removeItemsByCondition, 21 | removeItemsByProperty, 22 | removeItemsByProperties, 23 | removeItemsByPropertiesCondition, 24 | removeFalsyItems, 25 | removeFalsyItemsByProperty, 26 | removeFalsyItemsByProperties, 27 | removeFalsyItemsByPropertiesCondition, 28 | } from "../src/sortUtils"; 29 | import { expect, test, describe } from "@jest/globals"; 30 | 31 | describe("SortUtils", () => { 32 | test("sorts array of objects by key", () => { 33 | const array = [ 34 | { name: "Bob", age: 25 }, 35 | { name: "Alice", age: 30 }, 36 | ]; 37 | const sortedArray = sortArray(array, "age", "asc"); 38 | expect(sortedArray).toEqual([ 39 | { name: "Bob", age: 25 }, 40 | { name: "Alice", age: 30 }, 41 | ]); 42 | }); 43 | 44 | test("sorts array desc order", () => { 45 | const array = [ 46 | { name: "Bob", age: 25 }, 47 | { name: "Alice", age: 30 }, 48 | ]; 49 | const sorted = sortArray(array, "age", "desc"); 50 | expect(sorted[0].age).toBe(30); 51 | }); 52 | 53 | test("sortArray should not mutate input", () => { 54 | const array = [ 55 | { name: "Bob", age: 25 }, 56 | { name: "Alice", age: 30 }, 57 | ]; 58 | 59 | const original = [...array]; 60 | 61 | sortArray(array, "age", "desc"); 62 | 63 | expect(array).toEqual(original); 64 | }); 65 | 66 | test("sorts array of objects by multiple keys", () => { 67 | const array = [ 68 | { name: "Bob", age: 25 }, 69 | { name: "Alice", age: 30 }, 70 | { name: "Bob", age: 20 }, 71 | ]; 72 | const sortedArray = sortByMultipleKeys(array, [ 73 | { key: "name", order: "asc" }, 74 | { key: "age", order: "asc" }, 75 | ]); 76 | expect(sortedArray).toEqual([ 77 | { name: "Alice", age: 30 }, 78 | { name: "Bob", age: 20 }, 79 | { name: "Bob", age: 25 }, 80 | ]); 81 | }); 82 | 83 | test("sortByMultipleKeys should not mutate input", () => { 84 | const array = [ 85 | { name: "Bob", age: 25 }, 86 | { name: "Alice", age: 30 }, 87 | { name: "Bob", age: 20 }, 88 | ]; 89 | 90 | const original = [...array]; 91 | 92 | sortByMultipleKeys(array, [ 93 | { key: "name", order: "asc" }, 94 | { key: "age", order: "asc" }, 95 | ]); 96 | 97 | expect(array).toEqual(original); 98 | }); 99 | 100 | test("sorts array of objects using a custom comparator", () => { 101 | const array = [ 102 | { name: "Bob", age: 25 }, 103 | { name: "Alice", age: 30 }, 104 | ]; 105 | const sortedArray = sortByCustomComparator(array, (a, b) => a.age - b.age); 106 | expect(sortedArray).toEqual([ 107 | { name: "Bob", age: 25 }, 108 | { name: "Alice", age: 30 }, 109 | ]); 110 | }); 111 | 112 | test("sortByCustomComparator should not mutate input", () => { 113 | const array = [ 114 | { name: "Alice", age: 30 }, 115 | { name: "Bob", age: 25 }, 116 | ]; 117 | 118 | const original = [...array]; 119 | 120 | sortByCustomComparator(array, (a, b) => a.age - b.age); 121 | 122 | expect(array).toEqual(original); 123 | }); 124 | 125 | test("finds minimum element by key", () => { 126 | const array = [ 127 | { name: "Bob", age: 25 }, 128 | { name: "Alice", age: 30 }, 129 | { name: "Charlie", age: 20 }, 130 | ]; 131 | const minElement = minBy(array, "age"); 132 | expect(minElement).toEqual({ name: "Charlie", age: 20 }); 133 | }); 134 | 135 | test("finds maximum element by key", () => { 136 | const array = [ 137 | { name: "Bob", age: 25 }, 138 | { name: "Alice", age: 30 }, 139 | { name: "Charlie", age: 20 }, 140 | ]; 141 | const maxElement = maxBy(array, "age"); 142 | expect(maxElement).toEqual({ name: "Alice", age: 30 }); 143 | }); 144 | 145 | test("shuffles array", () => { 146 | const array = [1, 2, 3, 4, 5]; 147 | const shuffled = shuffleArray(array); 148 | expect(shuffled).toHaveLength(5); 149 | expect(shuffled).toEqual(expect.arrayContaining(array)); 150 | }); 151 | 152 | test("reverses array", () => { 153 | const array = [1, 2, 3, 4, 5]; 154 | const reversed = reverseArray(array); 155 | expect(reversed).toEqual([5, 4, 3, 2, 1]); 156 | expect(array).toEqual([1, 2, 3, 4, 5]); // Original unchanged 157 | }); 158 | 159 | test("rotates array", () => { 160 | const array = [1, 2, 3, 4, 5]; 161 | const rotated = rotateArray(array, 2); 162 | expect(rotated).toEqual([3, 4, 5, 1, 2]); 163 | }); 164 | 165 | test("rotates with times greater than length", () => { 166 | const array = [1, 2, 3]; 167 | const rotated = rotateArray(array, 5); // naive slice still returns a value 168 | expect(rotated).toEqual([...array.slice(5), ...array.slice(0, 5)]); 169 | }); 170 | 171 | test("chunks array", () => { 172 | const array = [1, 2, 3, 4, 5, 6, 7, 8]; 173 | const chunked = chunkArray(array, 3); 174 | expect(chunked).toEqual([ 175 | [1, 2, 3], 176 | [4, 5, 6], 177 | [7, 8], 178 | ]); 179 | }); 180 | 181 | test("chunks exact multiples of size", () => { 182 | const array = [1, 2, 3, 4, 5, 6]; 183 | const chunked = chunkArray(array, 3); 184 | expect(chunked).toEqual([ 185 | [1, 2, 3], 186 | [4, 5, 6], 187 | ]); 188 | }); 189 | 190 | test("flattens array", () => { 191 | const array = [ 192 | [1, 2], 193 | [3, 4], 194 | [5, 6], 195 | ]; 196 | const flattened = flattenArray(array); 197 | expect(flattened).toEqual([1, 2, 3, 4, 5, 6]); 198 | }); 199 | 200 | test("compacts array", () => { 201 | const array = [1, null, 2, undefined, 3, null]; 202 | const compacted = compactArray(array); 203 | expect(compacted).toEqual([1, 2, 3]); 204 | }); 205 | 206 | test("removes duplicates", () => { 207 | const array = [1, 2, 2, 3, 3, 3, 4]; 208 | const unique = removeDuplicates(array); 209 | expect(unique).toEqual([1, 2, 3, 4]); 210 | }); 211 | 212 | test("removes falsy values", () => { 213 | const array = [1, 0, 2, false, 3, "", 4, null, 5, undefined]; 214 | const filtered = removeFalsyValues(array); 215 | expect(filtered).toEqual([1, 2, 3, 4, 5]); 216 | }); 217 | 218 | test("removes falsy values and duplicates", () => { 219 | const array = [1, 0, 2, false, 2, 3, "", 3, 4, null, 4, 5, undefined]; 220 | const cleaned = removeFalsyAndDuplicates(array); 221 | expect(cleaned).toEqual([1, 2, 3, 4, 5]); 222 | }); 223 | 224 | test("removes specific item", () => { 225 | const array = [1, 2, 3, 2, 4]; 226 | const filtered = removeItem(array, 2); 227 | expect(filtered).toEqual([1, 3, 4]); 228 | }); 229 | 230 | test("removes multiple items", () => { 231 | const array = [1, 2, 3, 4, 5]; 232 | const filtered = removeItems(array, [2, 4]); 233 | expect(filtered).toEqual([1, 3, 5]); 234 | }); 235 | 236 | test("removes item by index", () => { 237 | const array = [1, 2, 3, 4, 5]; 238 | const filtered = removeItemByIndex(array, 2); 239 | expect(filtered).toEqual([1, 2, 4, 5]); 240 | }); 241 | 242 | test("removes items by multiple indexes", () => { 243 | const array = [1, 2, 3, 4, 5]; 244 | const filtered = removeItemsByIndex(array, [1, 3]); 245 | expect(filtered).toEqual([1, 3, 5]); 246 | }); 247 | 248 | test("removes items by condition", () => { 249 | const array = [1, 2, 3, 4, 5, 6]; 250 | const filtered = removeItemsByCondition(array, (x) => x % 2 === 0); 251 | expect(filtered).toEqual([1, 3, 5]); 252 | }); 253 | 254 | test("removes items by property", () => { 255 | const array = [ 256 | { name: "Alice", active: true }, 257 | { name: "Bob", active: false }, 258 | { name: "Charlie", active: true }, 259 | ]; 260 | const filtered = removeItemsByProperty(array, "active", [false]); 261 | expect(filtered).toEqual([ 262 | { name: "Alice", active: true }, 263 | { name: "Charlie", active: true }, 264 | ]); 265 | }); 266 | 267 | test("removes items by multiple properties", () => { 268 | const array = [ 269 | { name: "Alice", age: 25, active: true }, 270 | { name: "Bob", age: 30, active: false }, 271 | { name: "Charlie", age: 25, active: true }, 272 | ]; 273 | const filtered = removeItemsByProperties(array, ["age", "active"], [25, true]); 274 | expect(filtered).toEqual([{ name: "Bob", age: 30, active: false }]); 275 | }); 276 | 277 | test("removes items by properties condition", () => { 278 | const array = [ 279 | { name: "Alice", age: 25, score: 85 }, 280 | { name: "Bob", age: 30, score: 90 }, 281 | { name: "Charlie", age: 25, score: 75 }, 282 | ]; 283 | const filtered = removeItemsByPropertiesCondition( 284 | array, 285 | ["age", "score"], 286 | (values) => values[0] === 25 && (values[1] as number) < 80, 287 | ); 288 | expect(filtered).toEqual([ 289 | { name: "Alice", age: 25, score: 85 }, 290 | { name: "Bob", age: 30, score: 90 }, 291 | ]); 292 | }); 293 | 294 | test("removes falsy items", () => { 295 | const array = [1, 0, 2, false, 3, "", 4, null, 5, undefined]; 296 | const filtered = removeFalsyItems(array); 297 | expect(filtered).toEqual([1, 2, 3, 4, 5]); 298 | }); 299 | 300 | test("removes falsy items by property", () => { 301 | const array = [ 302 | { name: "Alice", active: true }, 303 | { name: "Bob", active: false }, 304 | { name: "Charlie", active: "" }, 305 | { name: "David", active: "yes" }, 306 | ]; 307 | const filtered = removeFalsyItemsByProperty(array, "active"); 308 | expect(filtered).toEqual([ 309 | { name: "Alice", active: true }, 310 | { name: "David", active: "yes" }, 311 | ]); 312 | }); 313 | 314 | test("removes falsy items by multiple properties", () => { 315 | const array = [ 316 | { name: "Alice", email: "alice@test.com", phone: "123" }, 317 | { name: "Bob", email: "", phone: "456" }, 318 | { name: "Charlie", email: "charlie@test.com", phone: "" }, 319 | { name: "David", email: "david@test.com", phone: "789" }, 320 | ]; 321 | const filtered = removeFalsyItemsByProperties(array, ["email", "phone"]); 322 | expect(filtered).toEqual([ 323 | { name: "Alice", email: "alice@test.com", phone: "123" }, 324 | { name: "David", email: "david@test.com", phone: "789" }, 325 | ]); 326 | }); 327 | 328 | test("removes falsy items by properties condition", () => { 329 | const array = [ 330 | { name: "Alice", score: 85, grade: "B" }, 331 | { name: "Bob", score: 0, grade: "F" }, 332 | { name: "Charlie", score: 90, grade: "" }, 333 | { name: "David", score: 75, grade: "C" }, 334 | ]; 335 | const filtered = removeFalsyItemsByPropertiesCondition( 336 | array, 337 | ["score", "grade"], 338 | (values) => Boolean(values[0]) && Boolean(values[1]), 339 | ); 340 | expect(filtered).toEqual([ 341 | { name: "Alice", score: 85, grade: "B" }, 342 | { name: "David", score: 75, grade: "C" }, 343 | ]); 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /src/dateUtils/dateUtils.types.ts: -------------------------------------------------------------------------------- 1 | const dateCountries = [ 2 | "es-ES", 3 | "en-GB", 4 | "en-US", 5 | "fr-FR", 6 | "de-DE", 7 | "it-IT", 8 | "pt-PT", 9 | "ru-RU", 10 | "zh-CN", 11 | "ja-JP", 12 | "ko-KR", 13 | "ar-SA", 14 | "hi-IN", 15 | "tr-TR", 16 | "vi-VN", 17 | "th-TH", 18 | "id-ID", 19 | "pl-PL", 20 | "uk-UA", 21 | "cs-CZ", 22 | "el-GR", 23 | "hu-HU", 24 | "ro-RO", 25 | "bg-BG", 26 | "sr-RS", 27 | "sk-SK", 28 | "hr-HR", 29 | "sl-SI", 30 | "et-EE", 31 | "lv-LV", 32 | "lt-LT", 33 | "fi-FI", 34 | "sv-SE", 35 | "da-DK", 36 | "no-NO", 37 | "nb-NO", 38 | "nn-NO", 39 | "is-IS", 40 | "nl-NL", 41 | "be-BY", 42 | "az-AZ", 43 | "hy-AM", 44 | "ka-GE", 45 | "mk-MK", 46 | "sq-AL", 47 | "eu-ES", 48 | "gl-ES", 49 | "ca-ES", 50 | "ga-IE", 51 | "mt-MT", 52 | "tr-TR", 53 | "he-IL", 54 | "ar-EG", 55 | "fa-IR", 56 | "ur-PK", 57 | "bn-BD", 58 | "pa-IN", 59 | "gu-IN", 60 | "or-IN", 61 | "ta-IN", 62 | "te-IN", 63 | "kn-IN", 64 | "ml-IN", 65 | "si-LK", 66 | "th-TH", 67 | "lo-LA", 68 | "my-MM", 69 | "km-KH", 70 | "ko-KR", 71 | "ja-JP", 72 | "zh-CN", 73 | "vi-VN", 74 | "id-ID", 75 | "tl-PH", 76 | "ms-MY", 77 | "jv-ID", 78 | "su-ID", 79 | "hi-IN", 80 | "bn-IN", 81 | "pa-IN", 82 | "gu-IN", 83 | "or-IN", 84 | "ta-IN", 85 | "te-IN", 86 | "kn-IN", 87 | "ml-IN", 88 | "si-LK", 89 | "th-TH", 90 | "lo-LA", 91 | "my-MM", 92 | "km-KH", 93 | "ko-KR", 94 | "ja-JP", 95 | "zh-CN", 96 | "vi-VN", 97 | "id-ID", 98 | "tl-PH", 99 | "ms-MY", 100 | "jv-ID", 101 | "su-ID", 102 | "hi-IN", 103 | "bn-IN", 104 | "pa-IN", 105 | "gu-IN", 106 | "or-IN", 107 | "ta-IN", 108 | "te-IN", 109 | "kn-IN", 110 | "ml-IN", 111 | ] as const; 112 | 113 | const timezones = [ 114 | // Can be any IANA time zone name, 115 | "UTC", 116 | "Europe/Andorra", 117 | "Asia/Dubai", 118 | "Asia/Kabul", 119 | "Europe/Tirane", 120 | "Asia/Yerevan", 121 | "Antarctica/Casey", 122 | "Antarctica/Davis", 123 | "Antarctica/DumontDUrville", 124 | "Antarctica/Mawson", 125 | "Antarctica/Palmer", 126 | "Antarctica/Rothera", 127 | "Antarctica/Syowa", 128 | "Antarctica/Troll", 129 | "Antarctica/Vostok", 130 | "America/Argentina/Buenos_Aires", 131 | "America/Argentina/Cordoba", 132 | "America/Argentina/Salta", 133 | "America/Argentina/Jujuy", 134 | "America/Argentina/Tucuman", 135 | "America/Argentina/Catamarca", 136 | "America/Argentina/La_Rioja", 137 | "America/Argentina/San_Juan", 138 | "America/Argentina/Mendoza", 139 | "America/Argentina/San_Luis", 140 | "America/Argentina/Rio_Gallegos", 141 | "America/Argentina/Ushuaia", 142 | "Pacific/Pago_Pago", 143 | "Europe/Vienna", 144 | "Australia/Lord_Howe", 145 | "Antarctica/Macquarie", 146 | "Australia/Hobart", 147 | "Australia/Melbourne", 148 | "Australia/Sydney", 149 | "Australia/Broken_Hill", 150 | "Australia/Brisbane", 151 | "Australia/Lindeman", 152 | "Australia/Adelaide", 153 | "Australia/Darwin", 154 | "Australia/Perth", 155 | "Australia/Eucla", 156 | "Asia/Baku", 157 | "America/Barbados", 158 | "Asia/Dhaka", 159 | "Europe/Brussels", 160 | "Europe/Sofia", 161 | "Atlantic/Bermuda", 162 | "Asia/Brunei", 163 | "America/La_Paz", 164 | "America/Noronha", 165 | "America/Belem", 166 | "America/Fortaleza", 167 | "America/Recife", 168 | "America/Araguaina", 169 | "America/Maceio", 170 | "America/Bahia", 171 | "America/Sao_Paulo", 172 | "America/Campo_Grande", 173 | "America/Cuiaba", 174 | "America/Santarem", 175 | "America/Porto_Velho", 176 | "America/Boa_Vista", 177 | "America/Manaus", 178 | "America/Eirunepe", 179 | "America/Rio_Branco", 180 | "America/Nassau", 181 | "Asia/Thimphu", 182 | "Europe/Minsk", 183 | "America/Belize", 184 | "America/St_Johns", 185 | "America/Halifax", 186 | "America/Glace_Bay", 187 | "America/Moncton", 188 | "America/Goose_Bay", 189 | "America/Blanc-Sablon", 190 | "America/Toronto", 191 | "America/Nipigon", 192 | "America/Thunder_Bay", 193 | "America/Iqaluit", 194 | "America/Pangnirtung", 195 | "America/Atikokan", 196 | "America/Winnipeg", 197 | "America/Rainy_River", 198 | "America/Resolute", 199 | "America/Rankin_Inlet", 200 | "America/Regina", 201 | "America/Swift_Current", 202 | "America/Edmonton", 203 | "America/Cambridge_Bay", 204 | "America/Yellowknife", 205 | "America/Inuvik", 206 | "America/Creston", 207 | "America/Dawson_Creek", 208 | "America/Fort_Nelson", 209 | "America/Whitehorse", 210 | "America/Dawson", 211 | "America/Vancouver", 212 | "Indian/Cocos", 213 | "Europe/Zurich", 214 | "Africa/Abidjan", 215 | "Pacific/Rarotonga", 216 | "America/Santiago", 217 | "America/Punta_Arenas", 218 | "Pacific/Easter", 219 | "Asia/Shanghai", 220 | "Asia/Urumqi", 221 | "America/Bogota", 222 | "America/Costa_Rica", 223 | "America/Havana", 224 | "Atlantic/Cape_Verde", 225 | "America/Curacao", 226 | "Indian/Christmas", 227 | "Asia/Nicosia", 228 | "Asia/Famagusta", 229 | "Europe/Prague", 230 | "Europe/Berlin", 231 | "Europe/Copenhagen", 232 | "America/Santo_Domingo", 233 | "Africa/Algiers", 234 | "America/Guayaquil", 235 | "Pacific/Galapagos", 236 | "Europe/Tallinn", 237 | "Africa/Cairo", 238 | "Africa/El_Aaiun", 239 | "Europe/Madrid", 240 | "Africa/Ceuta", 241 | "Atlantic/Canary", 242 | "Europe/Helsinki", 243 | "Pacific/Fiji", 244 | "Atlantic/Stanley", 245 | "Pacific/Chuuk", 246 | "Pacific/Pohnpei", 247 | "Pacific/Kosrae", 248 | "Atlantic/Faroe", 249 | "Europe/Paris", 250 | "Europe/London", 251 | "Asia/Tbilisi", 252 | "America/Cayenne", 253 | "Africa/Accra", 254 | "Europe/Gibraltar", 255 | "America/Nuuk", 256 | "America/Danmarkshavn", 257 | "America/Scoresbysund", 258 | "America/Thule", 259 | "Europe/Athens", 260 | "Atlantic/South_Georgia", 261 | "America/Guatemala", 262 | "Pacific/Guam", 263 | "Africa/Bissau", 264 | "America/Guyana", 265 | "Asia/Hong_Kong", 266 | "America/Tegucigalpa", 267 | "America/Port-au-Prince", 268 | "Europe/Budapest", 269 | "Asia/Jakarta", 270 | "Asia/Pontianak", 271 | "Asia/Makassar", 272 | "Asia/Jayapura", 273 | "Europe/Dublin", 274 | "Asia/Jerusalem", 275 | "Asia/Kolkata", 276 | "Indian/Chagos", 277 | "Asia/Baghdad", 278 | "Asia/Tehran", 279 | "Atlantic/Reykjavik", 280 | "Europe/Rome", 281 | "America/Jamaica", 282 | "Asia/Amman", 283 | "Asia/Tokyo", 284 | "Africa/Nairobi", 285 | "Asia/Bishkek", 286 | "Pacific/Tarawa", 287 | "Pacific/Enderbury", 288 | "Pacific/Kiritimati", 289 | "Asia/Pyongyang", 290 | "Asia/Seoul", 291 | "Asia/Almaty", 292 | "Asia/Qyzylorda", 293 | "Asia/Qostanay", 294 | "Asia/Aqtobe", 295 | "Asia/Aqtau", 296 | "Asia/Atyrau", 297 | "Asia/Oral", 298 | "Asia/Beirut", 299 | "Asia/Colombo", 300 | "Africa/Monrovia", 301 | "Europe/Vilnius", 302 | "Europe/Luxembourg", 303 | "Europe/Riga", 304 | "Africa/Tripoli", 305 | "Africa/Casablanca", 306 | "Europe/Monaco", 307 | "Europe/Chisinau", 308 | "Pacific/Majuro", 309 | "Pacific/Kwajalein", 310 | "Asia/Yangon", 311 | "Asia/Ulaanbaatar", 312 | "Asia/Hovd", 313 | "Asia/Choibalsan", 314 | "Asia/Macau", 315 | "America/Martinique", 316 | "Europe/Malta", 317 | "Indian/Mauritius", 318 | "Indian/Maldives", 319 | "America/Mexico_City", 320 | "America/Cancun", 321 | "America/Merida", 322 | "America/Monterrey", 323 | "America/Matamoros", 324 | "America/Mazatlan", 325 | "America/Chihuahua", 326 | "America/Ojinaga", 327 | "America/Hermosillo", 328 | "America/Tijuana", 329 | "America/Bahia_Banderas", 330 | "Asia/Kuala_Lumpur", 331 | "Asia/Kuching", 332 | "Africa/Maputo", 333 | "Africa/Windhoek", 334 | "Pacific/Noumea", 335 | "Pacific/Norfolk", 336 | "Africa/Lagos", 337 | "America/Managua", 338 | "Europe/Amsterdam", 339 | "Europe/Oslo", 340 | "Asia/Kathmandu", 341 | "Pacific/Nauru", 342 | "Pacific/Niue", 343 | "Pacific/Auckland", 344 | "Pacific/Chatham", 345 | "America/Panama", 346 | "America/Lima", 347 | "Pacific/Tahiti", 348 | "Pacific/Marquesas", 349 | "Pacific/Gambier", 350 | "Pacific/Port_Moresby", 351 | "Pacific/Bougainville", 352 | "Asia/Manila", 353 | "Asia/Karachi", 354 | "Europe/Warsaw", 355 | "America/Miquelon", 356 | "Pacific/Pitcairn", 357 | "America/Puerto_Rico", 358 | "Asia/Gaza", 359 | "Asia/Hebron", 360 | "Europe/Lisbon", 361 | "Atlantic/Madeira", 362 | "Atlantic/Azores", 363 | "Pacific/Palau", 364 | "America/Asuncion", 365 | "Asia/Qatar", 366 | "Indian/Reunion", 367 | "Europe/Bucharest", 368 | "Europe/Belgrade", 369 | "Europe/Kaliningrad", 370 | "Europe/Moscow", 371 | "Europe/Simferopol", 372 | "Europe/Kirov", 373 | "Europe/Volgograd", 374 | "Europe/Astrakhan", 375 | "Europe/Saratov", 376 | "Europe/Ulyanovsk", 377 | "Europe/Samara", 378 | "Asia/Yekaterinburg", 379 | "Asia/Omsk", 380 | "Asia/Novosibirsk", 381 | "Asia/Barnaul", 382 | "Asia/Tomsk", 383 | "Asia/Novokuznetsk", 384 | "Asia/Krasnoyarsk", 385 | "Asia/Irkutsk", 386 | "Asia/Chita", 387 | "Asia/Yakutsk", 388 | "Asia/Khandyga", 389 | "Asia/Vladivostok", 390 | "Asia/Ust-Nera", 391 | "Asia/Magadan", 392 | "Asia/Sakhalin", 393 | "Asia/Srednekolymsk", 394 | "Asia/Kamchatka", 395 | "Asia/Anadyr", 396 | "Asia/Riyadh", 397 | "Pacific/Guadalcanal", 398 | "Indian/Mahe", 399 | "Africa/Khartoum", 400 | "Europe/Stockholm", 401 | "Asia/Singapore", 402 | "America/Paramaribo", 403 | "Africa/Juba", 404 | "Africa/Sao_Tome", 405 | "America/El_Salvador", 406 | "Asia/Damascus", 407 | "America/Grand_Turk", 408 | "Africa/Ndjamena", 409 | "Indian/Kerguelen", 410 | "Asia/Bangkok", 411 | "Asia/Dushanbe", 412 | "Pacific/Fakaofo", 413 | "Asia/Dili", 414 | "Asia/Ashgabat", 415 | "Africa/Tunis", 416 | "Pacific/Tongatapu", 417 | "Europe/Istanbul", 418 | "America/Port_of_Spain", 419 | "Pacific/Funafuti", 420 | "Asia/Taipei", 421 | "Europe/Kiev", 422 | "Europe/Uzhgorod", 423 | "Europe/Zaporozhye", 424 | "Pacific/Wake", 425 | "America/New_York", 426 | "America/Detroit", 427 | "America/Kentucky/Louisville", 428 | "America/Kentucky/Monticello", 429 | "America/Indiana/Indianapolis", 430 | "America/Indiana/Vincennes", 431 | "America/Indiana/Winamac", 432 | "America/Indiana/Marengo", 433 | "America/Indiana/Petersburg", 434 | "America/Indiana/Vevay", 435 | "America/Chicago", 436 | "America/Indiana/Tell_City", 437 | "America/Indiana/Knox", 438 | "America/Menominee", 439 | "America/North_Dakota/Center", 440 | "America/North_Dakota/New_Salem", 441 | "America/North_Dakota/Beulah", 442 | "America/Denver", 443 | "America/Boise", 444 | "America/Phoenix", 445 | "America/Los_Angeles", 446 | "America/Anchorage", 447 | "America/Juneau", 448 | "America/Sitka", 449 | "America/Metlakatla", 450 | "America/Yakutat", 451 | "America/Nome", 452 | "America/Adak", 453 | "Pacific/Honolulu", 454 | "America/Montevideo", 455 | "Asia/Samarkand", 456 | "Asia/Tashkent", 457 | "America/Caracas", 458 | "Asia/Ho_Chi_Minh", 459 | "Pacific/Efate", 460 | "Pacific/Wallis", 461 | "Pacific/Apia", 462 | "Africa/Johannesburg", 463 | ] as const; 464 | 465 | export type DateCountry = (typeof dateCountries)[number]; 466 | export type Timezone = (typeof timezones)[number]; 467 | 468 | export type FormatDateOptions = { 469 | timezone: Timezone; 470 | locale: DateCountry; 471 | } 472 | 473 | export type DateFormat = "ISO_8601" | string 474 | 475 | export type TimeUnit = 'ms' | 's' | 'm' | 'h' | 'd' | 'w'; -------------------------------------------------------------------------------- /test/colorUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | hexToRgb, 3 | rgbToHex, 4 | rgbToHsl, 5 | hslToRgb, 6 | rgbToHsv, 7 | hsvToRgb, 8 | lighten, 9 | darken, 10 | saturate, 11 | desaturate, 12 | complement, 13 | analogous, 14 | triadic, 15 | tetradic, 16 | monochromatic, 17 | getContrast, 18 | isLight, 19 | isDark, 20 | randomColor, 21 | rgbString, 22 | hslString, 23 | type RGB, 24 | type HSL, 25 | type HSV, 26 | } from "../src/colorUtils"; 27 | import { describe, expect } from "@jest/globals"; 28 | 29 | describe("ColorUtils", () => { 30 | describe("hexToRgb", () => { 31 | it("should convert hex to RGB", () => { 32 | expect(hexToRgb("#FF0000")).toEqual({ r: 255, g: 0, b: 0 }); 33 | expect(hexToRgb("#00FF00")).toEqual({ r: 0, g: 255, b: 0 }); 34 | expect(hexToRgb("#0000FF")).toEqual({ r: 0, g: 0, b: 255 }); 35 | expect(hexToRgb("FF0000")).toEqual({ r: 255, g: 0, b: 0 }); 36 | expect(hexToRgb("#fff")).toEqual({ r: 255, g: 255, b: 255 }); 37 | }); 38 | 39 | it("should return null for invalid hex", () => { 40 | expect(hexToRgb("invalid")).toBeNull(); 41 | expect(hexToRgb("#GG0000")).toBeNull(); 42 | expect(hexToRgb("")).toBeNull(); 43 | }); 44 | }); 45 | 46 | describe("rgbToHex", () => { 47 | it("should convert RGB to hex", () => { 48 | expect(rgbToHex(255, 0, 0)).toBe("#ff0000"); 49 | expect(rgbToHex(0, 255, 0)).toBe("#00ff00"); 50 | expect(rgbToHex(0, 0, 255)).toBe("#0000ff"); 51 | expect(rgbToHex(255, 255, 255)).toBe("#ffffff"); 52 | expect(rgbToHex(0, 0, 0)).toBe("#000000"); 53 | }); 54 | 55 | it("should handle decimal values by rounding", () => { 56 | expect(rgbToHex(255.7, 0.3, 0.9)).toBe("#ff0001"); 57 | expect(rgbToHex(128.5, 128.5, 128.5)).toBe("#818181"); 58 | }); 59 | }); 60 | 61 | describe("rgbToHsl", () => { 62 | it("should convert RGB to HSL", () => { 63 | expect(rgbToHsl(255, 0, 0)).toEqual({ h: 0, s: 100, l: 50 }); 64 | expect(rgbToHsl(0, 255, 0)).toEqual({ h: 120, s: 100, l: 50 }); 65 | expect(rgbToHsl(0, 0, 255)).toEqual({ h: 240, s: 100, l: 50 }); 66 | expect(rgbToHsl(255, 255, 255)).toEqual({ h: 0, s: 0, l: 100 }); 67 | expect(rgbToHsl(0, 0, 0)).toEqual({ h: 0, s: 0, l: 0 }); 68 | }); 69 | }); 70 | 71 | describe("hslToRgb", () => { 72 | it("should convert HSL to RGB", () => { 73 | expect(hslToRgb(0, 100, 50)).toEqual({ r: 255, g: 0, b: 0 }); 74 | expect(hslToRgb(120, 100, 50)).toEqual({ r: 0, g: 255, b: 0 }); 75 | expect(hslToRgb(240, 100, 50)).toEqual({ r: 0, g: 0, b: 255 }); 76 | expect(hslToRgb(0, 0, 100)).toEqual({ r: 255, g: 255, b: 255 }); 77 | expect(hslToRgb(0, 0, 0)).toEqual({ r: 0, g: 0, b: 0 }); 78 | }); 79 | }); 80 | 81 | describe("rgbToHsv", () => { 82 | it("should convert RGB to HSV", () => { 83 | expect(rgbToHsv(255, 0, 0)).toEqual({ h: 0, s: 100, v: 100 }); 84 | expect(rgbToHsv(0, 255, 0)).toEqual({ h: 120, s: 100, v: 100 }); 85 | expect(rgbToHsv(0, 0, 255)).toEqual({ h: 240, s: 100, v: 100 }); 86 | expect(rgbToHsv(255, 255, 255)).toEqual({ h: 0, s: 0, v: 100 }); 87 | expect(rgbToHsv(0, 0, 0)).toEqual({ h: 0, s: 0, v: 0 }); 88 | }); 89 | }); 90 | 91 | describe("hsvToRgb", () => { 92 | it("should convert HSV to RGB", () => { 93 | expect(hsvToRgb(0, 100, 100)).toEqual({ r: 255, g: 0, b: 0 }); 94 | expect(hsvToRgb(120, 100, 100)).toEqual({ r: 0, g: 255, b: 0 }); 95 | expect(hsvToRgb(240, 100, 100)).toEqual({ r: 0, g: 0, b: 255 }); 96 | expect(hsvToRgb(0, 0, 100)).toEqual({ r: 255, g: 255, b: 255 }); 97 | expect(hsvToRgb(0, 0, 0)).toEqual({ r: 0, g: 0, b: 0 }); 98 | }); 99 | }); 100 | 101 | describe("lighten", () => { 102 | it("should lighten a color", () => { 103 | const originalHex = "#808080"; 104 | const lightened = lighten(originalHex, 20); 105 | expect(lightened).not.toBe(originalHex); 106 | 107 | // Should return original hex if invalid 108 | expect(lighten("invalid", 20)).toBe("invalid"); 109 | }); 110 | 111 | it("should not exceed maximum lightness", () => { 112 | const veryLight = lighten("#f0f0f0", 50); 113 | expect(veryLight).toBeTruthy(); 114 | }); 115 | }); 116 | 117 | describe("darken", () => { 118 | it("should darken a color", () => { 119 | const originalHex = "#808080"; 120 | const darkened = darken(originalHex, 20); 121 | expect(darkened).not.toBe(originalHex); 122 | 123 | // Should return original hex if invalid 124 | expect(darken("invalid", 20)).toBe("invalid"); 125 | }); 126 | 127 | it("should not go below minimum lightness", () => { 128 | const veryDark = darken("#101010", 50); 129 | expect(veryDark).toBeTruthy(); 130 | }); 131 | }); 132 | 133 | describe("saturate", () => { 134 | it("should increase saturation", () => { 135 | const originalHex = "#808080"; 136 | const saturated = saturate(originalHex, 50); 137 | expect(saturated).not.toBe(originalHex); 138 | 139 | // Should return original hex if invalid 140 | expect(saturate("invalid", 50)).toBe("invalid"); 141 | }); 142 | }); 143 | 144 | describe("desaturate", () => { 145 | it("should decrease saturation", () => { 146 | const originalHex = "#ff0000"; 147 | const desaturated = desaturate(originalHex, 50); 148 | expect(desaturated).not.toBe(originalHex); 149 | 150 | // Should return original hex if invalid 151 | expect(desaturate("invalid", 50)).toBe("invalid"); 152 | }); 153 | }); 154 | 155 | describe("complement", () => { 156 | it("should return complementary color", () => { 157 | expect(complement("#ff0000")).not.toBe("#ff0000"); 158 | expect(complement("invalid")).toBe("invalid"); 159 | }); 160 | 161 | it("should return cyan for red", () => { 162 | const redComplement = complement("#ff0000"); 163 | expect(redComplement).toBeTruthy(); 164 | expect(redComplement).not.toBe("#ff0000"); 165 | }); 166 | }); 167 | 168 | describe("analogous", () => { 169 | it("should return analogous colors", () => { 170 | const colors = analogous("#ff0000"); 171 | expect(colors).toHaveLength(3); 172 | expect(colors[0]).toBe("#ff0000"); 173 | expect(colors[1]).not.toBe("#ff0000"); 174 | expect(colors[2]).not.toBe("#ff0000"); 175 | }); 176 | 177 | it("should accept custom angle", () => { 178 | const colors = analogous("#ff0000", 45); 179 | expect(colors).toHaveLength(3); 180 | }); 181 | 182 | it("should handle invalid hex", () => { 183 | const colors = analogous("invalid"); 184 | expect(colors).toEqual(["invalid"]); 185 | }); 186 | }); 187 | 188 | describe("triadic", () => { 189 | it("should return triadic colors", () => { 190 | const colors = triadic("#ff0000"); 191 | expect(colors).toHaveLength(3); 192 | expect(colors[0]).toBe("#ff0000"); 193 | }); 194 | 195 | it("should handle invalid hex", () => { 196 | const colors = triadic("invalid"); 197 | expect(colors).toEqual(["invalid"]); 198 | }); 199 | }); 200 | 201 | describe("tetradic", () => { 202 | it("should return tetradic colors", () => { 203 | const colors = tetradic("#ff0000"); 204 | expect(colors).toHaveLength(4); 205 | expect(colors[0]).toBe("#ff0000"); 206 | }); 207 | 208 | it("should handle invalid hex", () => { 209 | const colors = tetradic("invalid"); 210 | expect(colors).toEqual(["invalid"]); 211 | }); 212 | }); 213 | 214 | describe("monochromatic", () => { 215 | it("should return monochromatic colors with default steps", () => { 216 | const colors = monochromatic("#ff0000"); 217 | expect(colors).toHaveLength(5); 218 | }); 219 | 220 | it("should return custom number of steps", () => { 221 | const colors = monochromatic("#ff0000", 7); 222 | expect(colors).toHaveLength(7); 223 | }); 224 | 225 | it("should handle invalid hex", () => { 226 | const colors = monochromatic("invalid"); 227 | expect(colors).toEqual(["invalid"]); 228 | }); 229 | }); 230 | 231 | describe("getContrast", () => { 232 | it("should calculate contrast ratio", () => { 233 | const contrast = getContrast("#ffffff", "#000000"); 234 | expect(contrast).toBeGreaterThan(1); 235 | 236 | // Same colors should have contrast ratio of 1 237 | const sameContrast = getContrast("#ff0000", "#ff0000"); 238 | expect(sameContrast).toBe(1); 239 | }); 240 | 241 | it("should handle invalid colors", () => { 242 | const contrast = getContrast("invalid", "#000000"); 243 | expect(contrast).toBe(1); 244 | }); 245 | }); 246 | 247 | describe("isLight", () => { 248 | it("should determine if color is light", () => { 249 | expect(isLight("#ffffff")).toBe(true); 250 | expect(isLight("#000000")).toBe(false); 251 | expect(isLight("#808080")).toBe(false); // Medium gray 252 | expect(isLight("#f0f0f0")).toBe(true); 253 | }); 254 | 255 | it("should handle invalid hex", () => { 256 | expect(isLight("invalid")).toBe(false); 257 | }); 258 | }); 259 | 260 | describe("isDark", () => { 261 | it("should determine if color is dark", () => { 262 | expect(isDark("#ffffff")).toBe(false); 263 | expect(isDark("#000000")).toBe(true); 264 | expect(isDark("#808080")).toBe(true); // Medium gray 265 | expect(isDark("#f0f0f0")).toBe(false); 266 | }); 267 | 268 | it("should be opposite of isLight", () => { 269 | const testColors = ["#ffffff", "#000000", "#ff0000", "#808080"]; 270 | testColors.forEach((color) => { 271 | expect(isDark(color)).toBe(!isLight(color)); 272 | }); 273 | }); 274 | }); 275 | 276 | describe("randomColor", () => { 277 | it("should generate random hex color", () => { 278 | const color = randomColor(); 279 | expect(color).toMatch(/^#[0-9a-f]{6}$/); 280 | }); 281 | 282 | it("should generate different colors", () => { 283 | const color1 = randomColor(); 284 | const color2 = randomColor(); 285 | // Very unlikely to be the same (though theoretically possible) 286 | expect(color1).toBeTruthy(); 287 | expect(color2).toBeTruthy(); 288 | }); 289 | }); 290 | 291 | describe("rgbString", () => { 292 | it("should create RGB string", () => { 293 | expect(rgbString(255, 0, 0)).toBe("rgb(255, 0, 0)"); 294 | expect(rgbString(0, 255, 0)).toBe("rgb(0, 255, 0)"); 295 | expect(rgbString(0, 0, 255)).toBe("rgb(0, 0, 255)"); 296 | }); 297 | 298 | it("should create RGBA string with alpha", () => { 299 | expect(rgbString(255, 0, 0, 0.5)).toBe("rgba(255, 0, 0, 0.5)"); 300 | expect(rgbString(0, 255, 0, 1)).toBe("rgba(0, 255, 0, 1)"); 301 | expect(rgbString(0, 0, 255, 0)).toBe("rgba(0, 0, 255, 0)"); 302 | }); 303 | }); 304 | 305 | describe("hslString", () => { 306 | it("should create HSL string", () => { 307 | expect(hslString(0, 100, 50)).toBe("hsl(0, 100%, 50%)"); 308 | expect(hslString(120, 100, 50)).toBe("hsl(120, 100%, 50%)"); 309 | expect(hslString(240, 100, 50)).toBe("hsl(240, 100%, 50%)"); 310 | }); 311 | 312 | it("should create HSLA string with alpha", () => { 313 | expect(hslString(0, 100, 50, 0.5)).toBe("hsla(0, 100%, 50%, 0.5)"); 314 | expect(hslString(120, 100, 50, 1)).toBe("hsla(120, 100%, 50%, 1)"); 315 | expect(hslString(240, 100, 50, 0)).toBe("hsla(240, 100%, 50%, 0)"); 316 | }); 317 | }); 318 | }); 319 | -------------------------------------------------------------------------------- /test/numberUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | average, 3 | clamp, 4 | countDecimals, 5 | factorial, 6 | fibonacci, 7 | formatCurrency, 8 | formatNumber, 9 | formatPercent, 10 | gcd, 11 | inRange, 12 | isEven, 13 | isOdd, 14 | isPrime, 15 | lcm, 16 | lerp, 17 | map, 18 | max, 19 | median, 20 | min, 21 | mode, 22 | parseCurrency, 23 | percentage, 24 | percentageOf, 25 | product, 26 | random, 27 | randomInt, 28 | range, 29 | round, 30 | standardDeviation, 31 | sum, 32 | toDegrees, 33 | toFixed, 34 | toRadians, 35 | variance, 36 | } from "../src/numberUtils"; 37 | import { describe, expect } from "@jest/globals"; 38 | 39 | describe("NumberUtils", () => { 40 | describe("clamp", () => { 41 | it("should clamp number within range", () => { 42 | expect(clamp(5, 1, 10)).toBe(5); 43 | expect(clamp(0, 1, 10)).toBe(1); 44 | expect(clamp(15, 1, 10)).toBe(10); 45 | }); 46 | }); 47 | 48 | describe("random", () => { 49 | it("should generate random number within range", () => { 50 | const result = random(1, 10); 51 | expect(result).toBeGreaterThanOrEqual(1); 52 | expect(result).toBeLessThan(10); 53 | }); 54 | }); 55 | 56 | describe("randomInt", () => { 57 | it("should generate random integer within range", () => { 58 | const result = randomInt(1, 10); 59 | expect(result).toBeGreaterThanOrEqual(1); 60 | expect(result).toBeLessThanOrEqual(10); 61 | expect(Number.isInteger(result)).toBe(true); 62 | }); 63 | it("should be deterministic with mocked Math.random", () => { 64 | const original = Math.random; 65 | Math.random = () => 0.9999999; // casi 1 -> extremo superior 66 | expect(randomInt(1, 10)).toBe(10); 67 | Math.random = () => 0; // extremo inferior 68 | expect(randomInt(1, 10)).toBe(1); 69 | Math.random = original; 70 | }); 71 | }); 72 | 73 | describe("round", () => { 74 | it("should round number to specified decimals", () => { 75 | expect(round(3.14159, 2)).toBe(3.14); 76 | expect(round(3.6)).toBe(4); 77 | expect(round(3.14159, 4)).toBe(3.1416); 78 | }); 79 | }); 80 | 81 | describe("toFixed", () => { 82 | it("should format number with fixed decimals", () => { 83 | expect(toFixed(3.14159, 2)).toBe("3.14"); 84 | expect(toFixed(3, 2)).toBe("3.00"); 85 | }); 86 | }); 87 | 88 | describe("isEven", () => { 89 | it("should check if number is even", () => { 90 | expect(isEven(2)).toBe(true); 91 | expect(isEven(3)).toBe(false); 92 | expect(isEven(0)).toBe(true); 93 | }); 94 | }); 95 | 96 | describe("isOdd", () => { 97 | it("should check if number is odd", () => { 98 | expect(isOdd(3)).toBe(true); 99 | expect(isOdd(2)).toBe(false); 100 | expect(isOdd(1)).toBe(true); 101 | }); 102 | }); 103 | 104 | describe("isPrime", () => { 105 | it("should check if number is prime", () => { 106 | expect(isPrime(2)).toBe(true); 107 | expect(isPrime(3)).toBe(true); 108 | expect(isPrime(4)).toBe(false); 109 | expect(isPrime(17)).toBe(true); 110 | expect(isPrime(1)).toBe(false); 111 | expect(isPrime(0)).toBe(false); 112 | }); 113 | }); 114 | 115 | describe("factorial", () => { 116 | it("should calculate factorial", () => { 117 | expect(factorial(0)).toBe(1); 118 | expect(factorial(1)).toBe(1); 119 | expect(factorial(5)).toBe(120); 120 | expect(factorial(-1)).toBe(-1); 121 | }); 122 | }); 123 | 124 | describe("fibonacci", () => { 125 | it("should calculate fibonacci number", () => { 126 | expect(fibonacci(0)).toBe(0); 127 | expect(fibonacci(1)).toBe(1); 128 | expect(fibonacci(6)).toBe(8); 129 | expect(fibonacci(10)).toBe(55); 130 | }); 131 | }); 132 | 133 | describe("gcd", () => { 134 | it("should calculate greatest common divisor", () => { 135 | expect(gcd(12, 18)).toBe(6); 136 | expect(gcd(48, 18)).toBe(6); 137 | expect(gcd(7, 13)).toBe(1); 138 | }); 139 | }); 140 | 141 | describe("lcm", () => { 142 | it("should calculate least common multiple", () => { 143 | expect(lcm(4, 6)).toBe(12); 144 | expect(lcm(3, 5)).toBe(15); 145 | }); 146 | }); 147 | 148 | describe("percentage", () => { 149 | it("should calculate percentage", () => { 150 | expect(percentage(25, 100)).toBe(25); 151 | expect(percentage(1, 4)).toBe(25); 152 | expect(percentage(3, 4)).toBe(75); 153 | }); 154 | it("should return 0 when total is 0 to avoid division by zero", () => { 155 | expect(percentage(50, 0)).toBe(0); 156 | expect(percentage(0, 0)).toBe(0); 157 | }); 158 | }); 159 | 160 | describe("percentageOf", () => { 161 | it("should calculate percentage of total", () => { 162 | expect(percentageOf(50, 200)).toBe(100); 163 | expect(percentageOf(25, 100)).toBe(25); 164 | }); 165 | }); 166 | 167 | describe("sum", () => { 168 | it("should calculate sum of array", () => { 169 | expect(sum([1, 2, 3, 4, 5])).toBe(15); 170 | expect(sum([])).toBe(0); 171 | expect(sum([5])).toBe(5); 172 | }); 173 | }); 174 | 175 | describe("average", () => { 176 | it("should calculate average of array", () => { 177 | expect(average([1, 2, 3, 4, 5])).toBe(3); 178 | expect(average([10, 20])).toBe(15); 179 | expect(average([])).toBe(0); 180 | }); 181 | }); 182 | 183 | describe("median", () => { 184 | it("should calculate median of array", () => { 185 | expect(median([1, 2, 3, 4, 5])).toBe(3); 186 | expect(median([1, 2, 3, 4])).toBe(2.5); 187 | expect(median([5, 1, 3])).toBe(3); 188 | }); 189 | }); 190 | 191 | describe("mode", () => { 192 | it("should find mode of array", () => { 193 | expect(mode([1, 2, 2, 3, 3, 3])).toEqual([3]); 194 | expect(mode([1, 1, 2, 2])).toEqual([1, 2]); 195 | }); 196 | }); 197 | 198 | describe("min", () => { 199 | it("should find minimum value", () => { 200 | expect(min([3, 1, 4, 1, 5])).toBe(1); 201 | expect(min([10])).toBe(10); 202 | }); 203 | }); 204 | 205 | describe("max", () => { 206 | it("should find maximum value", () => { 207 | expect(max([3, 1, 4, 1, 5])).toBe(5); 208 | expect(max([10])).toBe(10); 209 | }); 210 | }); 211 | 212 | describe("range", () => { 213 | it("should calculate range of array", () => { 214 | expect(range([1, 5, 3, 9, 2])).toBe(8); 215 | expect(range([5])).toBe(0); 216 | }); 217 | }); 218 | 219 | describe("standardDeviation", () => { 220 | it("should calculate standard deviation", () => { 221 | const result = standardDeviation([2, 4, 4, 4, 5, 5, 7, 9]); 222 | expect(result).toBeCloseTo(2, 0); 223 | }); 224 | }); 225 | 226 | describe("variance", () => { 227 | it("should calculate variance", () => { 228 | const result = variance([2, 4, 4, 4, 5, 5, 7, 9]); 229 | expect(result).toBeCloseTo(4, 0); 230 | }); 231 | }); 232 | 233 | describe("toDegrees", () => { 234 | it("should convert radians to degrees", () => { 235 | expect(toDegrees(Math.PI)).toBeCloseTo(180); 236 | expect(toDegrees(Math.PI / 2)).toBeCloseTo(90); 237 | }); 238 | }); 239 | 240 | describe("toRadians", () => { 241 | it("should convert degrees to radians", () => { 242 | expect(toRadians(180)).toBeCloseTo(Math.PI); 243 | expect(toRadians(90)).toBeCloseTo(Math.PI / 2); 244 | }); 245 | }); 246 | 247 | describe("formatNumber", () => { 248 | it("should format number with locale", () => { 249 | const result = formatNumber(1234.56); 250 | expect(typeof result).toBe("string"); 251 | expect(result).toContain("1"); 252 | }); 253 | }); 254 | 255 | describe("inRange", () => { 256 | it("should check if number is in range", () => { 257 | expect(inRange(5, 1, 10)).toBe(true); 258 | expect(inRange(0, 1, 10)).toBe(false); 259 | expect(inRange(11, 1, 10)).toBe(false); 260 | expect(inRange(1, 1, 10)).toBe(true); 261 | expect(inRange(10, 1, 10)).toBe(true); 262 | }); 263 | }); 264 | 265 | describe("lerp", () => { 266 | it("should interpolate between two values", () => { 267 | expect(lerp(0, 10, 0.5)).toBe(5); 268 | expect(lerp(10, 20, 0)).toBe(10); 269 | expect(lerp(10, 20, 1)).toBe(20); 270 | }); 271 | }); 272 | 273 | describe("map", () => { 274 | it("should map value from one range to another", () => { 275 | expect(map(5, 0, 10, 0, 100)).toBe(50); 276 | expect(map(2.5, 0, 5, 0, 10)).toBe(5); 277 | }); 278 | it("should return outMin if in range has zero length", () => { 279 | expect(map(5, 10, 10, 0, 100)).toBe(0); 280 | }); 281 | }); 282 | 283 | describe("product", () => { 284 | it("should calculate product of array", () => { 285 | expect(product([1, 2, 3, 4])).toBe(24); 286 | expect(product([5])).toBe(5); 287 | expect(product([])).toBe(1); 288 | expect(product([0, 2, 3])).toBe(0); 289 | }); 290 | }); 291 | 292 | describe("formatCurrency", () => { 293 | it("should format currency according to locale and currency", () => { 294 | const value = 1234.56; 295 | const expectedUS = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value); 296 | const expectedES = new Intl.NumberFormat("es-ES", { style: "currency", currency: "EUR" }).format(value); 297 | 298 | expect(formatCurrency(value, "USD", "en-US")).toBe(expectedUS); 299 | expect(formatCurrency(value, "EUR", "es-ES")).toBe(expectedES); 300 | }); 301 | }); 302 | 303 | describe("formatPercent", () => { 304 | it("should format percent according to locale", () => { 305 | const value = 0.257; // 25.7% 306 | const expectedUS = new Intl.NumberFormat("en-US", { style: "percent" }).format(value); 307 | const expectedES = new Intl.NumberFormat("es-ES", { style: "percent" }).format(value); 308 | 309 | expect(formatPercent(value, "en-US")).toBe(expectedUS); 310 | expect(formatPercent(value, "es-ES")).toBe(expectedES); 311 | }); 312 | }); 313 | 314 | describe("parseCurrency", () => { 315 | it("should parse currency strings into numbers", () => { 316 | expect(parseCurrency("$1,234.56")).toBeCloseTo(1234.56); 317 | expect(parseCurrency("€1.234,56")).toBeCloseTo(1234.56); 318 | expect(parseCurrency("£1 234,56")).toBeCloseTo(1234.56); 319 | expect(parseCurrency("1234.56")).toBeCloseTo(1234.56); 320 | expect(parseCurrency("1234,56")).toBeCloseTo(1234.56); 321 | expect(parseCurrency("1,234")).toBeCloseTo(1234); 322 | expect(parseCurrency("1.234", 'eu')).toBeCloseTo(1234); 323 | expect(parseCurrency("1.234", 'us')).toBeCloseTo(1.234); 324 | expect(parseCurrency("invalid")).toBeNaN(); 325 | expect(parseCurrency("")).toBeNaN(); 326 | expect(parseCurrency()).toBeNaN(); 327 | }); 328 | }); 329 | 330 | describe("countDecimals", () => { 331 | it("should count the number of decimal places", () => { 332 | expect(countDecimals(123.456)).toBe(3); 333 | expect(countDecimals(123.4)).toBe(1); 334 | expect(countDecimals(123)).toBe(0); 335 | expect(countDecimals(0.1)).toBe(1); 336 | expect(countDecimals(0.123456)).toBe(6); 337 | }); 338 | it("should handle scientific notation", () => { 339 | expect(countDecimals(1.23e-10)).toBe(12); 340 | expect(countDecimals(1.23e+5)).toBe(0); 341 | }); 342 | it("should return 0 for non-finite numbers", () => { 343 | expect(countDecimals(NaN)).toBe(0); 344 | expect(countDecimals(Infinity)).toBe(0); 345 | expect(countDecimals(-Infinity)).toBe(0); 346 | }); 347 | }); 348 | }); 349 | -------------------------------------------------------------------------------- /test/stringUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | capitalize, 3 | camelCase, 4 | kebabCase, 5 | snakeCase, 6 | pascalCase, 7 | titleCase, 8 | reverse, 9 | truncate, 10 | padStart, 11 | padEnd, 12 | removeAccents, 13 | slugify, 14 | extractNumbers, 15 | countWords, 16 | countCharacters, 17 | isEmail, 18 | isUrl, 19 | maskString, 20 | randomString, 21 | escapeHtml, 22 | unescapeHtml, 23 | stripHtml, 24 | highlightText, 25 | } from "../src/stringUtils"; 26 | import { jest, describe, expect } from "@jest/globals"; 27 | 28 | // Mock DOM for HTML manipulation functions 29 | global.document = { 30 | createElement: jest.fn(() => ({ 31 | textContent: "", 32 | innerHTML: "", 33 | innerText: "", 34 | })), 35 | } as any; 36 | 37 | describe("StringUtils", () => { 38 | describe("capitalize", () => { 39 | it("should capitalize first letter", () => { 40 | expect(capitalize("hello")).toBe("Hello"); 41 | expect(capitalize("WORLD")).toBe("World"); 42 | expect(capitalize("hello world")).toBe("Hello world"); 43 | expect(capitalize("")).toBe(""); 44 | expect(capitalize("a")).toBe("A"); 45 | }); 46 | }); 47 | 48 | describe("camelCase", () => { 49 | it("should convert to camelCase", () => { 50 | expect(camelCase("hello world")).toBe("helloWorld"); 51 | expect(camelCase("Hello World")).toBe("helloWorld"); 52 | expect(camelCase("HELLO WORLD")).toBe("helloWorld"); 53 | expect(camelCase("hello-world")).toBe("helloWorld"); 54 | expect(camelCase("hello_world")).toBe("helloWorld"); 55 | expect(camelCase("hello")).toBe("hello"); 56 | }); 57 | }); 58 | 59 | describe("kebabCase", () => { 60 | it("should convert to kebab-case", () => { 61 | expect(kebabCase("hello world")).toBe("hello-world"); 62 | expect(kebabCase("Hello World")).toBe("hello-world"); 63 | expect(kebabCase("helloWorld")).toBe("hello-world"); 64 | expect(kebabCase("hello_world")).toBe("hello-world"); 65 | expect(kebabCase("HELLO WORLD")).toBe("hello-world"); 66 | expect(kebabCase("hello")).toBe("hello"); 67 | }); 68 | }); 69 | 70 | describe("snakeCase", () => { 71 | it("should convert to snake_case", () => { 72 | expect(snakeCase("hello world")).toBe("hello_world"); 73 | expect(snakeCase("Hello World")).toBe("hello_world"); 74 | expect(snakeCase("helloWorld")).toBe("hello_world"); 75 | expect(snakeCase("hello-world")).toBe("hello_world"); 76 | expect(snakeCase("HELLO WORLD")).toBe("hello_world"); 77 | expect(snakeCase("hello")).toBe("hello"); 78 | }); 79 | }); 80 | 81 | describe("pascalCase", () => { 82 | it("should convert to PascalCase", () => { 83 | expect(pascalCase("hello world")).toBe("HelloWorld"); 84 | expect(pascalCase("hello-world")).toBe("HelloWorld"); 85 | expect(pascalCase("hello_world")).toBe("HelloWorld"); 86 | expect(pascalCase("helloWorld")).toBe("HelloWorld"); 87 | expect(pascalCase("hello")).toBe("Hello"); 88 | }); 89 | }); 90 | 91 | describe("titleCase", () => { 92 | it("should convert to Title Case", () => { 93 | expect(titleCase("hello world")).toBe("Hello World"); 94 | expect(titleCase("HELLO WORLD")).toBe("Hello World"); 95 | expect(titleCase("hello-world")).toBe("Hello-world"); 96 | expect(titleCase("the quick brown fox")).toBe("The Quick Brown Fox"); 97 | }); 98 | }); 99 | 100 | describe("reverse", () => { 101 | it("should reverse string", () => { 102 | expect(reverse("hello")).toBe("olleh"); 103 | expect(reverse("world")).toBe("dlrow"); 104 | expect(reverse("a")).toBe("a"); 105 | expect(reverse("")).toBe(""); 106 | expect(reverse("12345")).toBe("54321"); 107 | }); 108 | }); 109 | 110 | describe("truncate", () => { 111 | it("should truncate string with default suffix", () => { 112 | expect(truncate("hello world", 5)).toBe("hello..."); 113 | expect(truncate("hello", 10)).toBe("hello"); 114 | expect(truncate("hello world", 11)).toBe("hello world"); 115 | }); 116 | 117 | it("should truncate string with custom suffix", () => { 118 | expect(truncate("hello world", 5, "***")).toBe("hello***"); 119 | expect(truncate("hello world", 8, " more")).toBe("hello wo more"); 120 | }); 121 | }); 122 | 123 | describe("padStart", () => { 124 | it("should pad start with spaces", () => { 125 | expect(padStart("hello", 10)).toBe(" hello"); 126 | expect(padStart("hello", 5)).toBe("hello"); 127 | expect(padStart("hello", 3)).toBe("hello"); 128 | }); 129 | 130 | it("should pad start with custom character", () => { 131 | expect(padStart("hello", 10, "0")).toBe("00000hello"); 132 | expect(padStart("hello", 8, "*")).toBe("***hello"); 133 | }); 134 | }); 135 | 136 | describe("padEnd", () => { 137 | it("should pad end with spaces", () => { 138 | expect(padEnd("hello", 10)).toBe("hello "); 139 | expect(padEnd("hello", 5)).toBe("hello"); 140 | expect(padEnd("hello", 3)).toBe("hello"); 141 | }); 142 | 143 | it("should pad end with custom character", () => { 144 | expect(padEnd("hello", 10, "0")).toBe("hello00000"); 145 | expect(padEnd("hello", 8, "*")).toBe("hello***"); 146 | }); 147 | }); 148 | 149 | describe("removeAccents", () => { 150 | it("should remove accents from characters", () => { 151 | expect(removeAccents("café")).toBe("cafe"); 152 | expect(removeAccents("naïve")).toBe("naive"); 153 | expect(removeAccents("résumé")).toBe("resume"); 154 | expect(removeAccents("piñata")).toBe("pinata"); 155 | expect(removeAccents("hello")).toBe("hello"); 156 | }); 157 | }); 158 | 159 | describe("slugify", () => { 160 | it("should create URL-friendly slugs", () => { 161 | expect(slugify("Hello World")).toBe("hello-world"); 162 | expect(slugify("Café & Restaurant")).toBe("cafe-restaurant"); 163 | expect(slugify("This is a test!")).toBe("this-is-a-test"); 164 | expect(slugify("Multiple spaces")).toBe("multiple-spaces"); 165 | expect(slugify("Special@#$%Characters")).toBe("specialcharacters"); 166 | }); 167 | }); 168 | 169 | describe("extractNumbers", () => { 170 | it("should extract numbers from string", () => { 171 | expect(extractNumbers("Hello 123 World 456")).toEqual([123, 456]); 172 | expect(extractNumbers("Price: $99.99")).toEqual([99, 99]); 173 | expect(extractNumbers("No numbers here")).toEqual([]); 174 | expect(extractNumbers("Year 2023")).toEqual([2023]); 175 | expect(extractNumbers("")).toEqual([]); 176 | }); 177 | }); 178 | 179 | describe("countWords", () => { 180 | it("should count words in string", () => { 181 | expect(countWords("hello world")).toBe(2); 182 | expect(countWords("The quick brown fox")).toBe(4); 183 | expect(countWords(" hello world ")).toBe(2); 184 | expect(countWords("")).toBe(0); 185 | expect(countWords(" ")).toBe(0); 186 | expect(countWords("hello")).toBe(1); 187 | }); 188 | }); 189 | 190 | describe("countCharacters", () => { 191 | it("should count characters including spaces", () => { 192 | expect(countCharacters("hello world")).toBe(11); 193 | expect(countCharacters("hello")).toBe(5); 194 | expect(countCharacters("")).toBe(0); 195 | expect(countCharacters(" ")).toBe(2); 196 | }); 197 | 198 | it("should count characters excluding spaces", () => { 199 | expect(countCharacters("hello world", false)).toBe(10); 200 | expect(countCharacters("hello", false)).toBe(5); 201 | expect(countCharacters(" ", false)).toBe(0); 202 | expect(countCharacters("a b c", false)).toBe(3); 203 | }); 204 | }); 205 | 206 | describe("isEmail", () => { 207 | it("should validate email addresses", () => { 208 | expect(isEmail("test@example.com")).toBe(true); 209 | expect(isEmail("user.name+tag@domain.co.uk")).toBe(true); 210 | expect(isEmail("user@domain")).toBe(false); 211 | expect(isEmail("invalid-email")).toBe(false); 212 | expect(isEmail("@domain.com")).toBe(false); 213 | expect(isEmail("user@")).toBe(false); 214 | expect(isEmail("")).toBe(false); 215 | }); 216 | }); 217 | 218 | describe("isUrl", () => { 219 | it("should validate URLs", () => { 220 | expect(isUrl("https://example.com")).toBe(true); 221 | expect(isUrl("http://test.org")).toBe(true); 222 | expect(isUrl("ftp://files.example.com")).toBe(true); 223 | expect(isUrl("https://sub.domain.com/path?query=1")).toBe(true); 224 | expect(isUrl("invalid-url")).toBe(false); 225 | expect(isUrl("http://")).toBe(false); 226 | expect(isUrl("")).toBe(false); 227 | }); 228 | }); 229 | 230 | describe("maskString", () => { 231 | it("should mask string with default settings", () => { 232 | expect(maskString("1234567890")).toBe("12******90"); 233 | expect(maskString("hello")).toBe("he*lo"); 234 | expect(maskString("ab")).toBe("ab"); // Too short to mask 235 | }); 236 | 237 | it("should mask string with custom settings", () => { 238 | expect(maskString("1234567890", "#", 3, 3)).toBe("123####890"); 239 | expect(maskString("hello world", "*", 1, 1)).toBe("h*********d"); 240 | expect(maskString("test", "X", 1, 1)).toBe("tXXt"); 241 | }); 242 | }); 243 | 244 | describe("randomString", () => { 245 | it("should generate random string of specified length", () => { 246 | const result = randomString(10); 247 | expect(result).toHaveLength(10); 248 | expect(typeof result).toBe("string"); 249 | }); 250 | 251 | it("should generate different strings", () => { 252 | const result1 = randomString(10); 253 | const result2 = randomString(10); 254 | expect(result1).not.toBe(result2); 255 | }); 256 | 257 | it("should use custom character set", () => { 258 | const result = randomString(10, "ABC"); 259 | expect(result).toHaveLength(10); 260 | expect(result).toMatch(/^[ABC]+$/); 261 | }); 262 | }); 263 | 264 | describe("escapeHtml", () => { 265 | it("should escape HTML entities", () => { 266 | // Test basic functionality - the implementation uses DOM so may vary in test environment 267 | const result1 = escapeHtml(''); 268 | const result2 = escapeHtml("Hello & World"); 269 | 270 | expect(typeof result1).toBe("string"); 271 | expect(typeof result2).toBe("string"); 272 | }); 273 | }); 274 | 275 | describe("unescapeHtml", () => { 276 | it("should unescape HTML entities", () => { 277 | // Test basic functionality - the implementation uses DOM so may vary in test environment 278 | const result1 = unescapeHtml("<script>"); 279 | const result2 = unescapeHtml("Hello & World"); 280 | 281 | expect(typeof result1).toBe("string"); 282 | expect(typeof result2).toBe("string"); 283 | }); 284 | }); 285 | 286 | describe("stripHtml", () => { 287 | it("should remove HTML tags", () => { 288 | expect(stripHtml("

Hello World

")).toBe("Hello World"); 289 | expect(stripHtml("
Test
")).toBe("Test"); 290 | expect(stripHtml("No HTML here")).toBe("No HTML here"); 291 | expect(stripHtml('')).toBe('alert("xss")'); 292 | }); 293 | }); 294 | 295 | describe("highlightText", () => { 296 | it("should highlight search terms", () => { 297 | expect(highlightText("Hello World", "World")).toBe('Hello World'); 298 | expect(highlightText("JavaScript is great", "script")).toBe('JavaScript is great'); 299 | expect(highlightText("Test text", "")).toBe("Test text"); 300 | }); 301 | 302 | it("should use custom highlight class", () => { 303 | expect(highlightText("Hello World", "World", "custom")).toBe('Hello World'); 304 | }); 305 | 306 | it("should be case insensitive", () => { 307 | expect(highlightText("Hello WORLD", "world")).toBe('Hello WORLD'); 308 | }); 309 | }); 310 | }); 311 | -------------------------------------------------------------------------------- /test/objectUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | camelCaseObjectKeys, 3 | clone, 4 | countBy, 5 | entries, 6 | flatten, 7 | fromEntries, 8 | get, 9 | groupBy, 10 | has, 11 | indexBy, 12 | invert, 13 | isEmpty, 14 | isEqual, 15 | keys, 16 | mapKeys, 17 | mapValues, 18 | merge, 19 | omit, 20 | pick, 21 | set, 22 | snakeCaseObjectKeys, 23 | unflatten, 24 | values, 25 | } from "../src/objectUtils"; 26 | import { describe, expect } from "@jest/globals"; 27 | 28 | describe("ObjectUtils", () => { 29 | const testObject = { 30 | name: "John", 31 | age: 30, 32 | address: { 33 | city: "New York", 34 | zip: "10001", 35 | }, 36 | hobbies: ["reading", "coding"], 37 | }; 38 | 39 | describe("clone", () => { 40 | it("should deep clone object", () => { 41 | const cloned = clone(testObject); 42 | expect(cloned).toEqual(testObject); 43 | expect(cloned).not.toBe(testObject); 44 | expect(cloned.address).not.toBe(testObject.address); 45 | }); 46 | 47 | it("should handle primitive values", () => { 48 | expect(clone(5)).toBe(5); 49 | expect(clone("test")).toBe("test"); 50 | expect(clone(null)).toBe(null); 51 | }); 52 | }); 53 | 54 | describe("merge", () => { 55 | it("should merge objects deeply", () => { 56 | const obj1 = { a: 1, b: { c: 2 } }; 57 | const obj2 = { b: { d: 3 }, e: 4 }; 58 | const result = merge(obj1, obj2); 59 | 60 | expect(result).toEqual({ 61 | a: 1, 62 | b: { c: 2, d: 3 }, 63 | e: 4, 64 | }); 65 | }); 66 | }); 67 | 68 | describe("pick", () => { 69 | it("should pick specified properties", () => { 70 | const result = pick(testObject, ["name", "age"]); 71 | expect(result).toEqual({ name: "John", age: 30 }); 72 | }); 73 | }); 74 | 75 | describe("omit", () => { 76 | it("should omit specified properties", () => { 77 | const result = omit(testObject, ["age"]); 78 | expect(result).not.toHaveProperty("age"); 79 | expect(result).toHaveProperty("name"); 80 | }); 81 | }); 82 | 83 | describe("get", () => { 84 | it("should get nested property value", () => { 85 | expect(get(testObject, "name")).toBe("John"); 86 | expect(get(testObject, "address.city")).toBe("New York"); 87 | expect(get(testObject, "address.country", "USA")).toBe("USA"); 88 | }); 89 | 90 | it("should return default value when property not found", () => { 91 | expect(get(testObject, "nonexistent.property", "default")).toBe("default"); 92 | }); 93 | 94 | it("should return default value when accessing property on null", () => { 95 | const objWithNull = { data: null }; 96 | expect(get(objWithNull, "data.nested", "default")).toBe("default"); 97 | }); 98 | }); 99 | 100 | describe("set", () => { 101 | it("should set nested property value", () => { 102 | const obj = clone(testObject); 103 | set(obj, "address.country", "USA"); 104 | expect(get(obj, "address.country")).toBe("USA"); 105 | }); 106 | }); 107 | 108 | describe("has", () => { 109 | it("should check if object has property", () => { 110 | expect(has(testObject, "name")).toBe(true); 111 | expect(has(testObject, "address.city")).toBe(true); 112 | expect(has(testObject, "address.country")).toBe(false); 113 | }); 114 | }); 115 | 116 | describe("isEmpty", () => { 117 | it("should check if object is empty", () => { 118 | expect(isEmpty({})).toBe(true); 119 | expect(isEmpty({ a: 1 })).toBe(false); 120 | expect(isEmpty([])).toBe(true); 121 | expect(isEmpty([1])).toBe(false); 122 | expect(isEmpty("")).toBe(true); 123 | expect(isEmpty("test")).toBe(false); 124 | }); 125 | }); 126 | 127 | describe("isEqual", () => { 128 | it("should compare objects deeply", () => { 129 | const obj1 = { a: 1, b: { c: 2 } }; 130 | const obj2 = { a: 1, b: { c: 2 } }; 131 | const obj3 = { a: 1, b: { c: 3 } }; 132 | 133 | expect(isEqual(obj1, obj2)).toBe(true); 134 | expect(isEqual(obj1, obj3)).toBe(false); 135 | }); 136 | 137 | it("should handle null and undefined comparisons", () => { 138 | expect(isEqual(null, null)).toBe(true); // Actually null equals null 139 | expect(isEqual(undefined, undefined)).toBe(true); // Actually undefined equals undefined 140 | expect(isEqual(null, undefined)).toBe(false); 141 | expect(isEqual(null, "string")).toBe(false); // Line 110 test - null vs non-null 142 | expect(isEqual(undefined, 123)).toBe(false); // null vs non-null 143 | }); 144 | 145 | it("should handle different types", () => { 146 | expect(isEqual("string", 123)).toBe(false); // Line 111 test 147 | expect(isEqual({}, [])).toBe(false); 148 | }); 149 | 150 | it("should compare arrays", () => { 151 | expect(isEqual([1, 2, 3], [1, 2, 3])).toBe(true); 152 | expect(isEqual([1, 2], [1, 2, 3])).toBe(false); // Different lengths - Line 114 153 | expect(isEqual([1, 2, 3], [1, 2, 4])).toBe(false); 154 | }); 155 | }); 156 | 157 | describe("keys", () => { 158 | it("should get object keys", () => { 159 | const result = keys({ a: 1, b: 2, c: 3 }); 160 | expect(result).toEqual(["a", "b", "c"]); 161 | }); 162 | }); 163 | 164 | describe("values", () => { 165 | it("should get object values", () => { 166 | const result = values({ a: 1, b: 2, c: 3 }); 167 | expect(result).toEqual([1, 2, 3]); 168 | }); 169 | }); 170 | 171 | describe("entries", () => { 172 | it("should get object entries", () => { 173 | const result = entries({ a: 1, b: 2 }); 174 | expect(result).toEqual([ 175 | ["a", 1], 176 | ["b", 2], 177 | ]); 178 | }); 179 | }); 180 | 181 | describe("fromEntries", () => { 182 | it("should create object from entries", () => { 183 | const result = fromEntries([ 184 | ["a", 1], 185 | ["b", 2], 186 | ]); 187 | expect(result).toEqual({ a: 1, b: 2 }); 188 | }); 189 | }); 190 | 191 | describe("mapValues", () => { 192 | it("should map object values", () => { 193 | const result = mapValues({ a: 1, b: 2 }, (value) => value * 2); 194 | expect(result).toEqual({ a: 2, b: 4 }); 195 | }); 196 | }); 197 | 198 | describe("mapKeys", () => { 199 | it("should map object keys", () => { 200 | const result = mapKeys({ a: 1, b: 2 }, (key) => String(key).toUpperCase()); 201 | expect(result).toEqual({ 1: 1, 2: 2 }); // Keys se convierten a string del valor 202 | }); 203 | }); 204 | 205 | describe("invert", () => { 206 | it("should invert object keys and values", () => { 207 | const result = invert({ a: "x", b: "y", c: "z" }); 208 | expect(result).toEqual({ x: "a", y: "b", z: "c" }); 209 | }); 210 | }); 211 | 212 | describe("groupBy", () => { 213 | it("should group array by function result", () => { 214 | const items = [ 215 | { category: "A", value: 1 }, 216 | { category: "B", value: 2 }, 217 | { category: "A", value: 3 }, 218 | ]; 219 | const result = groupBy(items, (item) => item.category); 220 | expect(result.A).toHaveLength(2); 221 | expect(result.B).toHaveLength(1); 222 | }); 223 | }); 224 | 225 | describe("countBy", () => { 226 | it("should count array items by function result", () => { 227 | const items = ["a", "b", "a", "c", "b", "a"]; 228 | const result = countBy(items, (item) => item); 229 | expect(result).toEqual({ a: 3, b: 2, c: 1 }); 230 | }); 231 | }); 232 | 233 | describe("indexBy", () => { 234 | it("should index array by function result", () => { 235 | const items = [ 236 | { id: 1, name: "Alice" }, 237 | { id: 2, name: "Bob" }, 238 | ]; 239 | const result = indexBy(items, (item) => item.id); 240 | expect(result[1]).toEqual({ id: 1, name: "Alice" }); 241 | expect(result[2]).toEqual({ id: 2, name: "Bob" }); 242 | }); 243 | }); 244 | 245 | describe("flatten", () => { 246 | it("should flatten nested object", () => { 247 | const nested = { a: { b: { c: 1 } }, d: 2 }; 248 | const result = flatten(nested); 249 | expect(result).toEqual({ "a.b.c": 1, d: 2 }); 250 | }); 251 | }); 252 | 253 | describe("unflatten", () => { 254 | it("should unflatten object", () => { 255 | const flattened = { "a.b.c": 1, d: 2 }; 256 | const result = unflatten(flattened); 257 | expect(result).toEqual({ a: { b: { c: 1 } }, d: 2 }); 258 | }); 259 | }); 260 | 261 | describe("camelCaseObjectKeys", () => { 262 | it("should convert object keys to camelCase (deep)", () => { 263 | const obj = { first_name: "John", last_name: "Doe", address_info: { street_name: "Main St" } }; 264 | const result = camelCaseObjectKeys(obj, true); 265 | expect(result).toEqual({ firstName: "John", lastName: "Doe", addressInfo: { streetName: "Main St" } }); 266 | }); 267 | 268 | it("should convert object keys to camelCase", () => { 269 | const obj = { first_name: "John", last_name: "Doe", address_info: { street_name: "Main St" } }; 270 | const result = camelCaseObjectKeys(obj, false); 271 | expect(result).toEqual({ firstName: "John", lastName: "Doe", addressInfo: { street_name: "Main St" } }); 272 | }); 273 | 274 | it("should handle arrays of objects when deep is true", () => { 275 | const obj = { 276 | users: [ 277 | { first_name: "John" }, 278 | { first_name: "Jane" }, 279 | ], 280 | }; 281 | const result = camelCaseObjectKeys(obj, true); 282 | expect(result).toEqual({ 283 | users: [ 284 | { firstName: "John" }, 285 | { firstName: "Jane" }, 286 | ], 287 | }); 288 | }); 289 | 290 | it("should not modify original object", () => { 291 | const obj = { first_name: "John", last_name: "Doe" }; 292 | const result = camelCaseObjectKeys(obj); 293 | expect(obj).toEqual({ first_name: "John", last_name: "Doe" }); 294 | expect(result).toEqual({ firstName: "John", lastName: "Doe" }); 295 | }); 296 | 297 | it("should handle empty object", () => { 298 | const obj = {}; 299 | const result = camelCaseObjectKeys(obj); 300 | expect(result).toEqual({}); 301 | }); 302 | 303 | it("should handle non-object values gracefully", () => { 304 | expect(camelCaseObjectKeys(null as any)).toEqual({}); 305 | expect(camelCaseObjectKeys(123 as any)).toEqual({}); 306 | expect(camelCaseObjectKeys("string" as any)).toEqual({}); 307 | }); 308 | }); 309 | 310 | describe("snakeCaseObjectKeys", () => { 311 | it("should convert object keys to snake_case (deep)", () => { 312 | const obj = { firstName: "John", lastName: "Doe", addressInfo: { streetName: "Main St" } }; 313 | const result = snakeCaseObjectKeys(obj, true); 314 | expect(result).toEqual({ first_name: "John", last_name: "Doe", address_info: { street_name: "Main St" } }); 315 | }); 316 | 317 | it("should convert object keys to snake_case", () => { 318 | const obj = { firstName: "John", lastName: "Doe", addressInfo: { streetName: "Main St" } }; 319 | const result = snakeCaseObjectKeys(obj, false); 320 | expect(result).toEqual({ first_name: "John", last_name: "Doe", address_info: { streetName: "Main St" } }); 321 | }); 322 | 323 | it("should handle arrays of objects when deep is true", () => { 324 | const obj = { 325 | users: [ 326 | { firstName: "John" }, 327 | { firstName: "Jane" }, 328 | ], 329 | }; 330 | const result = snakeCaseObjectKeys(obj, true); 331 | expect(result).toEqual({ 332 | users: [ 333 | { first_name: "John" }, 334 | { first_name: "Jane" }, 335 | ], 336 | }); 337 | }); 338 | 339 | it("should not modify original object", () => { 340 | const obj = { firstName: "John", lastName: "Doe" }; 341 | const result = snakeCaseObjectKeys(obj); 342 | expect(obj).toEqual({ firstName: "John", lastName: "Doe" }); 343 | expect(result).toEqual({ first_name: "John", last_name: "Doe" }); 344 | }); 345 | 346 | it("should handle empty object", () => { 347 | const obj = {}; 348 | const result = snakeCaseObjectKeys(obj); 349 | expect(result).toEqual({}); 350 | }); 351 | 352 | it("should handle non-object values gracefully", () => { 353 | expect(snakeCaseObjectKeys(null as any)).toEqual({}); 354 | expect(snakeCaseObjectKeys(123 as any)).toEqual({}); 355 | expect(snakeCaseObjectKeys("string" as any)).toEqual({}); 356 | }); 357 | }); 358 | }); 359 | -------------------------------------------------------------------------------- /test/fileUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | formatFileSize, 3 | getFileExtensionFromPath, 4 | getFileName, 5 | getFileNameWithoutExtension, 6 | isImageFile, 7 | isVideoFile, 8 | isAudioFile, 9 | isDocumentFile, 10 | isArchiveFile, 11 | isCodeFile, 12 | getMimeType, 13 | downloadFile, 14 | readFileAsText, 15 | readFileAsDataURL, 16 | readFileAsArrayBuffer, 17 | validateFileType, 18 | validateFileSize, 19 | generateUniqueFileName, 20 | sanitizeFileName, 21 | parseCSV, 22 | arrayToCSV, 23 | compressImage, 24 | } from "../src/fileUtils"; 25 | import { jest, describe, expect } from "@jest/globals"; 26 | 27 | // Mock DOM APIs for testing 28 | global.document = { 29 | createElement: jest.fn((tagName: string) => { 30 | const element: any = { 31 | tagName: tagName.toUpperCase(), 32 | href: "", 33 | download: "", 34 | click: jest.fn(), 35 | appendChild: jest.fn(), 36 | removeChild: jest.fn(), 37 | }; 38 | return element; 39 | }), 40 | body: { 41 | appendChild: jest.fn(), 42 | removeChild: jest.fn(), 43 | }, 44 | } as any; 45 | 46 | global.URL = { 47 | createObjectURL: jest.fn(() => "blob:mock-url"), 48 | revokeObjectURL: jest.fn(), 49 | } as any; 50 | 51 | global.FileReader = class { 52 | onload: ((event: any) => void) | null = null; 53 | onerror: ((event: any) => void) | null = null; 54 | result: string | ArrayBuffer | null = null; 55 | 56 | readAsText(file: File) { 57 | setTimeout(() => { 58 | this.result = "mock file content"; 59 | if (this.onload) this.onload({} as any); 60 | }, 0); 61 | } 62 | 63 | readAsDataURL(file: File) { 64 | setTimeout(() => { 65 | this.result = "data:text/plain;base64,bW9jayBmaWxlIGNvbnRlbnQ="; 66 | if (this.onload) this.onload({} as any); 67 | }, 0); 68 | } 69 | 70 | readAsArrayBuffer(file: File) { 71 | setTimeout(() => { 72 | this.result = new ArrayBuffer(8); 73 | if (this.onload) this.onload({} as any); 74 | }, 0); 75 | } 76 | } as any; 77 | 78 | global.Blob = class { 79 | constructor(public parts: any[], public options?: BlobPropertyBag) {} 80 | } as any; 81 | 82 | describe("FileUtils", () => { 83 | describe("formatFileSize", () => { 84 | it("should format file sizes correctly", () => { 85 | expect(formatFileSize(0)).toBe("0 Bytes"); 86 | expect(formatFileSize(1024)).toBe("1 KB"); 87 | expect(formatFileSize(1024 * 1024)).toBe("1 MB"); 88 | expect(formatFileSize(1024 * 1024 * 1024)).toBe("1 GB"); 89 | expect(formatFileSize(1024 * 1024 * 1024 * 1024)).toBe("1 TB"); 90 | expect(formatFileSize(1536)).toBe("1.5 KB"); 91 | expect(formatFileSize(1234567)).toBe("1.18 MB"); 92 | }); 93 | }); 94 | 95 | describe("getFileExtensionFromPath", () => { 96 | it("should extract file extensions", () => { 97 | expect(getFileExtensionFromPath("file.txt")).toBe("txt"); 98 | expect(getFileExtensionFromPath("image.PNG")).toBe("png"); 99 | expect(getFileExtensionFromPath("path/to/file.js")).toBe("js"); 100 | expect(getFileExtensionFromPath("file.tar.gz")).toBe("gz"); 101 | expect(getFileExtensionFromPath("noextension")).toBe(""); 102 | expect(getFileExtensionFromPath("")).toBe(""); 103 | }); 104 | }); 105 | 106 | describe("getFileName", () => { 107 | it("should extract filename from path", () => { 108 | expect(getFileName("/path/to/file.txt")).toBe("file.txt"); 109 | expect(getFileName("C:\\Windows\\file.exe")).toBe("file.exe"); 110 | expect(getFileName("file.txt")).toBe("file.txt"); 111 | expect(getFileName("/path/to/")).toBe(""); 112 | expect(getFileName("")).toBe(""); 113 | }); 114 | }); 115 | 116 | describe("getFileNameWithoutExtension", () => { 117 | it("should remove extension from filename", () => { 118 | expect(getFileNameWithoutExtension("file.txt")).toBe("file"); 119 | expect(getFileNameWithoutExtension("image.PNG")).toBe("image"); 120 | expect(getFileNameWithoutExtension("file.tar.gz")).toBe("file.tar"); 121 | expect(getFileNameWithoutExtension("noextension")).toBe("noextension"); 122 | expect(getFileNameWithoutExtension("")).toBe(""); 123 | }); 124 | }); 125 | 126 | describe("isImageFile", () => { 127 | it("should identify image files", () => { 128 | expect(isImageFile("image.jpg")).toBe(true); 129 | expect(isImageFile("photo.PNG")).toBe(true); 130 | expect(isImageFile("icon.svg")).toBe(true); 131 | expect(isImageFile("pic.webp")).toBe(true); 132 | expect(isImageFile("document.pdf")).toBe(false); 133 | expect(isImageFile("video.mp4")).toBe(false); 134 | expect(isImageFile("noextension")).toBe(false); 135 | }); 136 | }); 137 | 138 | describe("isVideoFile", () => { 139 | it("should identify video files", () => { 140 | expect(isVideoFile("movie.mp4")).toBe(true); 141 | expect(isVideoFile("clip.AVI")).toBe(true); 142 | expect(isVideoFile("video.webm")).toBe(true); 143 | expect(isVideoFile("film.mkv")).toBe(true); 144 | expect(isVideoFile("audio.mp3")).toBe(false); 145 | expect(isVideoFile("image.jpg")).toBe(false); 146 | expect(isVideoFile("document.pdf")).toBe(false); 147 | }); 148 | }); 149 | 150 | describe("isAudioFile", () => { 151 | it("should identify audio files", () => { 152 | expect(isAudioFile("song.mp3")).toBe(true); 153 | expect(isAudioFile("audio.WAV")).toBe(true); 154 | expect(isAudioFile("music.flac")).toBe(true); 155 | expect(isAudioFile("sound.ogg")).toBe(true); 156 | expect(isAudioFile("video.mp4")).toBe(false); 157 | expect(isAudioFile("image.jpg")).toBe(false); 158 | expect(isAudioFile("document.pdf")).toBe(false); 159 | }); 160 | }); 161 | 162 | describe("isDocumentFile", () => { 163 | it("should identify document files", () => { 164 | expect(isDocumentFile("document.pdf")).toBe(true); 165 | expect(isDocumentFile("text.TXT")).toBe(true); 166 | expect(isDocumentFile("spreadsheet.xlsx")).toBe(true); 167 | expect(isDocumentFile("presentation.pptx")).toBe(true); 168 | expect(isDocumentFile("image.jpg")).toBe(false); 169 | expect(isDocumentFile("video.mp4")).toBe(false); 170 | expect(isDocumentFile("audio.mp3")).toBe(false); 171 | }); 172 | }); 173 | 174 | describe("isArchiveFile", () => { 175 | it("should identify archive files", () => { 176 | expect(isArchiveFile("archive.zip")).toBe(true); 177 | expect(isArchiveFile("backup.RAR")).toBe(true); 178 | expect(isArchiveFile("compressed.7z")).toBe(true); 179 | expect(isArchiveFile("tarball.tar")).toBe(true); 180 | expect(isArchiveFile("document.pdf")).toBe(false); 181 | expect(isArchiveFile("image.jpg")).toBe(false); 182 | }); 183 | }); 184 | 185 | describe("isCodeFile", () => { 186 | it("should identify code files", () => { 187 | expect(isCodeFile("script.js")).toBe(true); 188 | expect(isCodeFile("component.TSX")).toBe(true); 189 | expect(isCodeFile("style.css")).toBe(true); 190 | expect(isCodeFile("config.json")).toBe(true); 191 | expect(isCodeFile("data.yml")).toBe(true); 192 | expect(isCodeFile("main.py")).toBe(true); 193 | expect(isCodeFile("document.pdf")).toBe(false); 194 | expect(isCodeFile("image.jpg")).toBe(false); 195 | }); 196 | }); 197 | 198 | describe("getMimeType", () => { 199 | it("should return correct MIME types", () => { 200 | expect(getMimeType("image.jpg")).toBe("image/jpeg"); 201 | expect(getMimeType("document.pdf")).toBe("application/pdf"); 202 | expect(getMimeType("script.js")).toBe("application/javascript"); 203 | expect(getMimeType("style.css")).toBe("text/css"); 204 | expect(getMimeType("data.json")).toBe("application/json"); 205 | expect(getMimeType("unknown.xyz")).toBe("application/octet-stream"); 206 | }); 207 | }); 208 | 209 | describe("downloadFile", () => { 210 | it("should trigger file download", () => { 211 | // Test that the function doesn't throw an error 212 | expect(() => downloadFile("test content", "test.txt")).not.toThrow(); 213 | }); 214 | 215 | it("should handle Blob content", () => { 216 | const blob = new Blob(["test content"]); 217 | expect(() => downloadFile(blob, "test.txt")).not.toThrow(); 218 | }); 219 | }); 220 | 221 | describe("readFileAsText", () => { 222 | it("should read file as text", async () => { 223 | const mockFile = new File(["content"], "test.txt", { type: "text/plain" }); 224 | const result = await readFileAsText(mockFile); 225 | expect(result).toBe("mock file content"); 226 | }); 227 | }); 228 | 229 | describe("readFileAsDataURL", () => { 230 | it("should read file as data URL", async () => { 231 | const mockFile = new File(["content"], "test.txt", { type: "text/plain" }); 232 | const result = await readFileAsDataURL(mockFile); 233 | expect(result).toBe("data:text/plain;base64,bW9jayBmaWxlIGNvbnRlbnQ="); 234 | }); 235 | }); 236 | 237 | describe("readFileAsArrayBuffer", () => { 238 | it("should read file as array buffer", async () => { 239 | const mockFile = new File(["content"], "test.txt", { type: "text/plain" }); 240 | const result = await readFileAsArrayBuffer(mockFile); 241 | expect(result).toBeInstanceOf(ArrayBuffer); 242 | }); 243 | }); 244 | 245 | describe("validateFileType", () => { 246 | it("should validate file types", () => { 247 | const txtFile = new File(["content"], "test.txt", { type: "text/plain" }); 248 | const jpgFile = new File(["content"], "image.jpg", { type: "image/jpeg" }); 249 | 250 | expect(validateFileType(txtFile, ["txt", "pdf"])).toBe(true); 251 | expect(validateFileType(jpgFile, ["txt", "pdf"])).toBe(false); 252 | expect(validateFileType(jpgFile, ["jpg", "png", "gif"])).toBe(true); 253 | expect(validateFileType(txtFile, ["TXT"])).toBe(true); // Case insensitive 254 | }); 255 | }); 256 | 257 | describe("validateFileSize", () => { 258 | it("should validate file sizes", () => { 259 | const smallFile = new File(["small"], "small.txt", { type: "text/plain" }); 260 | const largeFile = new File([new ArrayBuffer(2000)], "large.bin", { type: "application/octet-stream" }); 261 | 262 | expect(validateFileSize(smallFile, 1000)).toBe(true); 263 | expect(validateFileSize(largeFile, 1000)).toBe(false); 264 | expect(validateFileSize(largeFile, 3000)).toBe(true); 265 | }); 266 | }); 267 | 268 | describe("generateUniqueFileName", () => { 269 | it("should generate unique filenames", () => { 270 | const original = "test.txt"; 271 | const unique1 = generateUniqueFileName(original); 272 | const unique2 = generateUniqueFileName(original); 273 | 274 | expect(unique1).not.toBe(original); 275 | expect(unique2).not.toBe(original); 276 | expect(unique1).not.toBe(unique2); 277 | expect(unique1).toMatch(/test_\d+_[a-z0-9]+\.txt/); 278 | expect(unique2).toMatch(/test_\d+_[a-z0-9]+\.txt/); 279 | }); 280 | 281 | it("should preserve extension", () => { 282 | expect(generateUniqueFileName("document.pdf")).toMatch(/\.pdf$/); 283 | expect(generateUniqueFileName("image.PNG")).toMatch(/\.PNG$/); 284 | expect(generateUniqueFileName("noext")).not.toMatch(/\./); 285 | }); 286 | }); 287 | 288 | describe("sanitizeFileName", () => { 289 | it("should sanitize filenames", () => { 290 | expect(sanitizeFileName("valid-name.txt")).toBe("valid-name.txt"); 291 | expect(sanitizeFileName("file with spaces.txt")).toBe("file_with_spaces.txt"); 292 | expect(sanitizeFileName("bad<>chars.txt")).toBe("bad_chars.txt"); 293 | expect(sanitizeFileName("multiple spaces.txt")).toBe("multiple_spaces.txt"); 294 | expect(sanitizeFileName("___starts_and_ends___.txt")).toBe("starts_and_ends_.txt"); 295 | expect(sanitizeFileName('file"with|bad*chars?.txt')).toBe("file_with_bad_chars_.txt"); 296 | }); 297 | }); 298 | 299 | describe("parseCSV", () => { 300 | it("should parse CSV data", () => { 301 | const csvData = `name,age,city 302 | John,25,New York 303 | Jane,30,Boston 304 | Bob,35,Chicago`; 305 | 306 | const result = parseCSV(csvData); 307 | expect(result).toEqual([ 308 | ["name", "age", "city"], 309 | ["John", "25", "New York"], 310 | ["Jane", "30", "Boston"], 311 | ["Bob", "35", "Chicago"], 312 | ]); 313 | }); 314 | 315 | it("should handle custom delimiter", () => { 316 | const csvData = "name;age;city\nJohn;25;New York"; 317 | const result = parseCSV(csvData, ";"); 318 | expect(result).toEqual([ 319 | ["name", "age", "city"], 320 | ["John", "25", "New York"], 321 | ]); 322 | }); 323 | 324 | it("should handle quoted fields", () => { 325 | const csvData = 'name,description\n"John","A person with, comma"'; 326 | const result = parseCSV(csvData); 327 | expect(result).toEqual([ 328 | ["name", "description"], 329 | ["John", "A person with, comma"], 330 | ]); 331 | }); 332 | 333 | it("should skip empty lines", () => { 334 | const csvData = "name,age\n\nJohn,25\n\nJane,30\n"; 335 | const result = parseCSV(csvData); 336 | expect(result).toEqual([ 337 | ["name", "age"], 338 | ["John", "25"], 339 | ["Jane", "30"], 340 | ]); 341 | }); 342 | }); 343 | 344 | describe("arrayToCSV", () => { 345 | it("should convert array to CSV", () => { 346 | const data = [ 347 | ["name", "age", "city"], 348 | ["John", 25, "New York"], 349 | ["Jane", 30, "Boston"], 350 | ]; 351 | 352 | const result = arrayToCSV(data); 353 | expect(result).toBe("name,age,city\nJohn,25,New York\nJane,30,Boston"); 354 | }); 355 | 356 | it("should handle custom delimiter", () => { 357 | const data = [ 358 | ["name", "age"], 359 | ["John", 25], 360 | ]; 361 | const result = arrayToCSV(data, ";"); 362 | expect(result).toBe("name;age\nJohn;25"); 363 | }); 364 | 365 | it("should escape special characters", () => { 366 | const data = [ 367 | ["name", "description"], 368 | ["John", "A person with, comma"], 369 | ["Jane", 'Quote: "Hello"'], 370 | ["Bob", "Line\nbreak"], 371 | ]; 372 | 373 | const result = arrayToCSV(data); 374 | expect(result).toContain('"A person with, comma"'); 375 | expect(result).toContain('"Quote: ""Hello"""'); 376 | expect(result).toContain('"Line\nbreak"'); 377 | }); 378 | }); 379 | 380 | describe("compressImage", () => { 381 | // Mock Image and Canvas for testing 382 | const mockCanvas = { 383 | width: 0, 384 | height: 0, 385 | getContext: jest.fn(() => ({ 386 | drawImage: jest.fn(), 387 | })), 388 | toBlob: jest.fn((callback: (blob: Blob) => void, type?: string, quality?: number) => { 389 | const blob = new Blob(["compressed image data"]); 390 | callback(blob); 391 | }), 392 | }; 393 | 394 | beforeEach(() => { 395 | (global.document.createElement as jest.Mock) = jest.fn((tagName) => { 396 | if (tagName === "canvas") return mockCanvas; 397 | return {}; 398 | }); 399 | 400 | global.Image = class { 401 | onload: (() => void) | null = null; 402 | onerror: (() => void) | null = null; 403 | width = 1920; 404 | height = 1080; 405 | 406 | set src(value: string) { 407 | setTimeout(() => { 408 | if (this.onload) this.onload(); 409 | }, 0); 410 | } 411 | } as any; 412 | }); 413 | 414 | it("should compress image", async () => { 415 | const mockFile = new File(["image data"], "image.jpg", { type: "image/jpeg" }); 416 | const result = await compressImage(mockFile); 417 | 418 | expect(result).toBeInstanceOf(Blob); 419 | expect(mockCanvas.toBlob).toHaveBeenCalled(); 420 | }); 421 | 422 | it("should use custom quality and dimensions", async () => { 423 | const mockFile = new File(["image data"], "image.jpg", { type: "image/jpeg" }); 424 | await compressImage(mockFile, 0.5, 800, 600); 425 | 426 | expect(mockCanvas.toBlob).toHaveBeenCalledWith(expect.any(Function), "image/jpeg", 0.5); 427 | }); 428 | }); 429 | }); 430 | --------------------------------------------------------------------------------