diff --git a/package.json b/package.json index 49dd2ed..02e4046 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.24.12", + "version": "0.25.0", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/arg-parser.ts b/src/arg-parser.ts index da7111b..45f6dff 100644 --- a/src/arg-parser.ts +++ b/src/arg-parser.ts @@ -72,10 +72,11 @@ export class ArgParser { extras.push(arg); continue; } - const value = argDef.default === false ? true : - argDef.default === true ? false : - queue.splice(queue.findIndex(q => q[0] != '-'), 1)[0] || - argDef.default; + const value = combined[1] != null ? combined [1] : + argDef.default === false ? true : + argDef.default === true ? false : + queue.splice(queue.findIndex(q => q[0] != '-'), 1)[0] || + argDef.default; if(value == null) parsed['_error'].push(`Option missing value: ${argDef.name || combined[0]}`); parsed[argDef.name] = value; } else { // Command diff --git a/src/array.ts b/src/array.ts index 09247b8..384b3cb 100644 --- a/src/array.ts +++ b/src/array.ts @@ -1,3 +1,4 @@ +import {ASet} from './aset.ts'; import {dotNotation, isEqual} from './objects'; /** @@ -28,10 +29,7 @@ export function addUnique(array: T[], el: T): T[] { * @deprecated Use ASet to perform Set operations on arrays */ export function arrayDiff(a: any[], b: any[]): any[] { - return makeUnique([ - ...a.filter(v1 => !b.includes((v2: any) => isEqual(v1, v2))), - ...b.filter(v1 => !a.includes((v2: any) => isEqual(v1, v2))), - ]); + return new ASet(a).symmetricDifference(new ASet(b)); } /** diff --git a/src/cache.ts b/src/cache.ts index 54e96b2..a79d42b 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -99,18 +99,20 @@ export class Cache { /** * Remove all keys from cache */ - clear() { + clear(): this { this.complete = false; this.store = {}; + return this; } /** * Delete an item from the cache * @param {K} key Item's primary key */ - delete(key: K) { + delete(key: K): this { delete this.store[key]; this.save(); + return this; } /** @@ -126,10 +128,11 @@ export class Cache { * Manually expire a cached item * @param {K} key Key to expire */ - expire(key: K) { + expire(key: K): this { this.complete = false; if(this.options.expiryPolicy == 'keep') (this.store[key])._expired = true; else this.delete(key); + return this; } /** @@ -137,7 +140,7 @@ export class Cache { * @param {K} key Key to lookup * @return {T} Cached item */ - get(key: K, expired?: boolean): T | null { + get(key: K, expired?: boolean): CachedValue | null { const cached = deepCopy(this.store[key] ?? null); if(expired || !cached?._expired) return cached; return null; @@ -178,7 +181,7 @@ export class Cache { if(ttl) setTimeout(() => { this.expire(key); this.save(); - }, ttl * 1000); + }, (ttl || 0) * 1000); return this; } diff --git a/src/color.ts b/src/color.ts index 36fa6e4..d0996e9 100644 --- a/src/color.ts +++ b/src/color.ts @@ -3,10 +3,10 @@ * @param {string} background Color to compare against * @return {"white" | "black"} Color with the most contrast */ -export function blackOrWhite(background: string): 'white' | 'black' { - const exploded = background?.match(background.length >= 6 ? /\w\w/g : /\w/g); - if(!exploded) return 'black'; - const [r, g, b] = exploded.map(hex => parseInt(hex, 16)); +export function contrast(background: string): 'white' | 'black' { + const exploded = background?.match(background.length >= 6 ? /[0-9a-fA-F]{2}/g : /[0-9a-fA-F]/g); + if(!exploded || exploded?.length < 3) return 'black'; + const [r, g, b] = exploded.map(hex => parseInt(hex.length == 1 ? `${hex}${hex}` : hex, 16)); const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return luminance > 0.5 ? 'black' : 'white'; } diff --git a/src/emitter.ts b/src/emitter.ts index 38e9dec..e7a1ae1 100644 --- a/src/emitter.ts +++ b/src/emitter.ts @@ -17,7 +17,7 @@ export class TypedEmitter { static off(event: any, listener: TypedListener) { const e = event.toString(); - this.listeners[e] = (this.listeners[e] || []).filter(l => l === listener); + this.listeners[e] = (this.listeners[e] || []).filter(l => l != listener); } static on(event: any, listener: TypedListener) { @@ -43,7 +43,7 @@ export class TypedEmitter { }; off(event: K, listener: T[K]) { - this.listeners[event] = (this.listeners[event] || []).filter(l => l === listener); + this.listeners[event] = (this.listeners[event] || []).filter(l => l != listener); } on(event: K, listener: T[K]) { diff --git a/src/jwt.ts b/src/jwt.ts index 0420167..d81853c 100644 --- a/src/jwt.ts +++ b/src/jwt.ts @@ -1,7 +1,23 @@ import {JSONAttemptParse} from './objects.ts'; /** - * Decode a JWT payload, this will not check for tampering so be careful + * Creates a JSON Web Token (JWT) using the provided payload. + * + * @param {object} payload The payload to include in the JWT. + * @param signature Add a JWT signature + * @return {string} The generated JWT string. + */ +export function createJwt(payload: object, signature = 'unsigned'): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })) + .toString('base64url'); + const body = Buffer.from(JSON.stringify(payload)) + .toString('base64url'); + // Signature is irrelevant for decodeJwt + return `${header}.${body}.${signature}`; +} + +/** + * Decode a JSON Web Token (JWT) payload, this will not check for tampering so be careful * * @param {string} token JWT to decode * @return {unknown} JWT payload diff --git a/src/math.ts b/src/math.ts index 5b79f74..aac5c91 100644 --- a/src/math.ts +++ b/src/math.ts @@ -7,25 +7,30 @@ * ``` * * @param {number} num Number to convert + * @param maxDen * @return {string} Fraction with remainder */ -export function dec2Frac(num: number) { - const gcd = (a: number, b: number): number => { - if (b < 0.0000001) return a; - return gcd(b, ~~(a % b)); - }; - - const len = num.toString().length - 2; - let denominator = Math.pow(10, len); - let numerator = num * denominator; - const divisor = gcd(numerator, denominator); - numerator = ~~(numerator / divisor); - denominator = ~~(denominator / divisor) - const remainder = ~~(numerator / denominator); - numerator -= remainder * denominator; - return `${remainder ? remainder + ' ' : ''}${~~(numerator)}/${~~(denominator)}`; +export function dec2Frac(num: number, maxDen=1000): string { + let sign = Math.sign(num); + num = Math.abs(num); + if (Number.isInteger(num)) return (sign * num) + ""; + let closest = { n: 0, d: 1, diff: Math.abs(num) }; + for (let d = 1; d <= maxDen; d++) { + let n = Math.round(num * d); + let diff = Math.abs(num - n / d); + if (diff < closest.diff) { + closest = { n, d, diff }; + if (diff < 1e-8) break; // Close enough + } + } + let integer = Math.floor(closest.n / closest.d); + let numerator = closest.n - integer * closest.d; + return (sign < 0 ? '-' : '') + + (integer ? integer + ' ' : '') + + (numerator ? numerator + '/' + closest.d : ''); } + /** * Convert fraction to decimal number * diff --git a/src/misc.ts b/src/misc.ts index 16e0db8..50577d4 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -35,5 +35,14 @@ export function escapeRegex(value: string) { return value.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&'); } +/** + * Represents a function that listens for events and handles them accordingly. + * + * @param {PathEvent} event - The event object containing data related to the triggered event. + * @param {...any} args - Additional arguments that may be passed to the listener. + * @returns {any} The return value of the listener, which can vary based on implementation. + */ export type Listener = (event: PathEvent, ...args: any[]) => any; + +/** Represents a function that can be called to unsubscribe from an event, stream, or observer */ export type Unsubscribe = () => void; diff --git a/src/objects.ts b/src/objects.ts index 62a0244..804106b 100644 --- a/src/objects.ts +++ b/src/objects.ts @@ -14,7 +14,7 @@ export function clean(obj: T, undefinedOnly = false): Partial { if(obj == null) throw new Error("Cannot clean a NULL value"); if(Array.isArray(obj)) { - obj = obj.filter(o => o != null); + obj = obj.filter(o => undefinedOnly ? o !== undefined : o != null); } else { Object.entries(obj).forEach(([key, value]) => { if((undefinedOnly && value === undefined) || (!undefinedOnly && value == null)) delete (obj)[key]; @@ -128,7 +128,6 @@ export function flattenObj(obj: any, parent?: any, result: any = {}) { for(const key of Object.keys(obj)) { const propName = parent ? `${parent}.${key}` : key; if(typeof obj[key] === 'object' && obj[key] != null && !Array.isArray(obj[key])) { - console.log(propName, ); flattenObj(obj[key], propName, result); } else { result[propName] = obj[key]; @@ -242,10 +241,12 @@ export function JSONSerialize(obj: T1): T1 | string { * @return {string} JSON string */ export function JSONSanitize(obj: any, space?: number): string { - let cache: any[] = []; + const cache: any[] = []; return JSON.stringify(obj, (key, value) => { - if(typeof value === 'object' && value !== null) - if(!cache.includes(value)) cache.push(value); + if (typeof value === 'object' && value !== null) { + if (cache.includes(value)) return '[Circular]'; + cache.push(value); + } return value; }, space); } diff --git a/src/path-events.ts b/src/path-events.ts index 94f4180..dbdb0c2 100644 --- a/src/path-events.ts +++ b/src/path-events.ts @@ -120,6 +120,7 @@ export class PathEvent { const l1 = p1.fullPath.length, l2 = p2.fullPath.length; return l1 < l2 ? 1 : (l1 > l2 ? -1 : 0); }).reduce((acc, p) => { + if(acc && !acc.fullPath.startsWith(p.fullPath)) return acc; if(p.none) hitNone = true; if(!acc) return p; if(hitNone) return acc; @@ -253,8 +254,8 @@ export class PathEventEmitter implements IPathEventEmitter{ constructor(public readonly prefix: string = '') { } emit(event: Event, ...args: any[]) { - const parsed = new PathEvent(`${this.prefix}/${typeof event == 'string' ? event : event.toString()}`); - this.listeners.filter(l => PathEvent.has(l[0], event)) + const parsed = new PathEvent(`${this.prefix}/${new PathEvent(event).toString()}`); + this.listeners.filter(l => PathEvent.has(l[0], `${this.prefix}/${event}`)) .forEach(async l => l[1](parsed, ...args)); }; @@ -264,7 +265,7 @@ export class PathEventEmitter implements IPathEventEmitter{ on(event: Event | Event[], listener: PathListener): PathUnsubscribe { makeArray(event).forEach(e => this.listeners.push([ - new PathEvent(`${this.prefix}/${typeof e == 'string' ? event : event.toString()}`), + new PathEvent(`${this.prefix}/${new PathEvent(e).toString()}`), listener ])); return () => this.off(listener); diff --git a/src/search.ts b/src/search.ts index ebb3866..a86a42a 100644 --- a/src/search.ts +++ b/src/search.ts @@ -1,5 +1,14 @@ import {dotNotation, JSONAttemptParse, JSONSerialize} from './objects.ts'; +/** + * Filters an array of objects based on a search term and optional regex checking. + * + * @param {Array} rows Array of objects to filter + * @param {string} search The logic string or regext to filter on + * @param {boolean} [regex=false] Treat search expression as regex + * @param {Function} [transform=(r) => r] - Transform rows before filtering + * @return {Array} The filtered array of objects that matched search + */ export function search(rows: any[], search: string, regex?: boolean, transform: Function = (r: any) => r) { if(!rows) return []; return rows.filter(r => { diff --git a/src/string.ts b/src/string.ts index 4c20420..43a8ebf 100644 --- a/src/string.ts +++ b/src/string.ts @@ -23,7 +23,7 @@ export const CHAR_LIST = LETTER_LIST + LETTER_LIST.toLowerCase() + NUMBER_LIST + */ export function camelCase(str?: string) { const text = pascalCase(str); - return text[0].toLowerCase() + text.slice(1); + return !text ? '' : text[0].toLowerCase() + text.slice(1); } /** @@ -110,13 +110,14 @@ export function pad(text: any, length: number, char: string = ' ', start = true) * @param {string} str * @return {string} */ -export function pascalCase(str?: string) { +export function pascalCase(str?: string): string { if(!str) return ''; - const text = str.replaceAll(/(^[^a-zA-Z]+|[^a-zA-Z0-9-_])/g, '') - .replaceAll(/[^a-zA-Z0-9]+(\w?)/g, (...args) => args[1]?.toUpperCase() || ''); - return text[0].toUpperCase() + text.slice(1); + return str.match(/[a-zA-Z0-9]+/g) + ?.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join('') ?? ''; } + /** * Generate a random hexadecimal value * diff --git a/src/types.ts b/src/types.ts index d46e47a..5b52437 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,27 +1,4 @@ -/** - * Return keys on a type as an array of strings - * - * @example - * ```ts - * type Person = { - * firstName: string; - * lastName: string; - * age: number; - * } - * - * const keys = typeKeys(); - * console.log(keys); // Output: ["firstName", "lastName", "age"] - * ``` - * - * @return {Array} Available keys - */ -export function typeKeys() { - return Object.keys({}) as Array; -} - -/** - * Mark all properties as writable - */ +/** Mark all properties as writable */ export type Writable = { -readonly [P in keyof T]: T[P] }; diff --git a/tests/arg-parser.spec.ts b/tests/arg-parser.spec.ts new file mode 100644 index 0000000..92e9def --- /dev/null +++ b/tests/arg-parser.spec.ts @@ -0,0 +1,121 @@ +import { Arg, ArgParser } from '../src'; + +describe('ArgParser', () => { + const basicArgs: Arg[] = [ + { name: 'input', desc: 'Input file' }, + { name: 'output', desc: 'Output file', default: 'out.txt' }, + { name: 'verbose', desc: 'Enable verbose mode', flags: ['-v', '--verbose'], default: false } + ]; + + const commandArg = new ArgParser( + 'subcmd', + 'A sub command', + [{ name: 'foo', desc: 'Foo argument', optional: false }] + ); + + describe('constructor', () => { + it('should add commands and update examples', () => { + const parser = new ArgParser('main', 'desc', [commandArg, ...basicArgs], ['custom-example']); + expect(parser.commands[0].name).toBe('subcmd'); + expect(parser.examples.some(e => typeof e === 'string' && e.includes('[OPTIONS] COMMAND'))).toBe(true); + expect(parser.examples).toEqual(expect.arrayContaining([ + 'custom-example', + expect.stringContaining('[OPTIONS]') + ])); + }); + }); + + describe('parse', () => { + it('should parse args and flags', () => { + const parser = new ArgParser('mycmd', 'desc', basicArgs); + const result = parser.parse(['file1.txt', '-v']); + expect(result.input).toBe('file1.txt'); + expect(result.output).toBe('out.txt'); + expect(result.verbose).toBe(true); + expect(result._extra).toEqual([]); + }); + + it('should handle missing required args and collect errors', () => { + const parser = new ArgParser('mycmd', 'desc', basicArgs); + const result = parser.parse([]); + expect(result._error).toContain('Argument missing: INPUT'); + }); + + it('should handle default values correctly', () => { + const parser = new ArgParser('mycmd', 'desc', basicArgs); + const result = parser.parse(['file1.txt']); + expect(result.output).toBe('out.txt'); + expect(result.verbose).toBe(false); + }); + + it('should parse flags with value assignment', () => { + const args: Arg[] = [ + { name: 'mode', desc: 'Mode', flags: ['-m', '--mode'], default: 'defaultMode' } + ]; + const parser = new ArgParser('mycmd', 'desc', args); + const result = parser.parse(['--mode=prod']); + expect(result.mode).toBe('prod'); + }); + + it('should support extras collection', () => { + const args: Arg[] = [ + { name: 'main', desc: 'main', extras: true } + ]; + const parser = new ArgParser('cmd', 'desc', args); + const result = parser.parse(['a', 'b', 'c']); + expect(result.main).toEqual(['a', 'b', 'c']); + }); + + it('should handle unknown flags and put them to extras', () => { + const parser = new ArgParser('mycmd', 'desc', basicArgs); + const result = parser.parse(['file.txt', 'test', '--unknown']); + expect(result._extra).toContain('--unknown'); + }); + + it('should handle subcommands and delegate parsing', () => { + const mainParser = new ArgParser('main', 'desc', [commandArg]); + const result = mainParser.parse(['subcmd', 'fooVal']); + expect(result._command).toBe('subcmd'); + expect(result.foo).toBe('fooVal'); + }); + + it('should parse combined short flags', () => { + const args: Arg[] = [ + { name: 'a', desc: 'Flag A', flags: ['-a'], default: false }, + { name: 'b', desc: 'Flag B', flags: ['-b'], default: false } + ]; + const parser = new ArgParser('mycmd', 'desc', args); + const result = parser.parse(['-ab']); + expect(result.a).toBe(true); + expect(result.b).toBe(true); + }); + }); + + describe('help', () => { + it('should generate help with options and args', () => { + const parser = new ArgParser('mycmd', 'desc', basicArgs); + const helpMsg = parser.help(); + expect(helpMsg).toContain('Input file'); + expect(helpMsg).toContain('Enable verbose mode'); + expect(helpMsg).toContain('-h, --help'); + }); + + it('should generate help for a subcommand', () => { + const mainParser = new ArgParser('main', 'desc', [commandArg]); + const helpMsg = mainParser.help({ command: 'subcmd' }); + expect(helpMsg).toContain('Foo argument'); + }); + + it('should throw error for non-existent command', () => { + const mainParser = new ArgParser('main', 'desc', [commandArg]); + expect(() => mainParser.help({ command: 'notreal' })).toThrow(); + }); + + it('should allow custom message override', () => { + const parser = new ArgParser('mycmd', 'desc', basicArgs); + const helpMsg = parser.help({ message: 'Custom!' }); + expect(helpMsg).toContain('Custom!'); + expect(helpMsg).not.toContain('desc'); + }); + }); +}); diff --git a/tests/array.spec.ts b/tests/array.spec.ts index 2aa8c34..bfc1a20 100644 --- a/tests/array.spec.ts +++ b/tests/array.spec.ts @@ -1,82 +1,85 @@ -import {addUnique, caseInsensitiveSort, flattenArr, sortByProp} from '../src'; +import {addUnique, arrayDiff, caseInsensitiveSort, findByProp, flattenArr, makeArray, makeUnique, sortByProp,} from '../src'; describe('Array Utilities', () => { describe('addUnique', () => { - const arr = [1, 2]; - - test('non-unique', () => { - addUnique(arr, 1); - expect(arr).toStrictEqual([1, 2]); + it('does not add duplicate value', () => { + const arr = [1, 2, 3]; + addUnique(arr, 2); + expect(arr).toEqual([1, 2, 3]); }); - test('unique', () => { + it('adds unique value', () => { + const arr = [1, 2]; addUnique(arr, 3); - expect(arr).toStrictEqual([1, 2, 3]); + expect(arr).toEqual([1, 2, 3]); + }); + }); + + describe('arrayDiff', () => { + it('returns unique elements present only in one array', () => { + expect(arrayDiff([1, 2, 3], [3, 4, 5]).toSorted()).toEqual([1, 2, 4, 5]); + }); + it('returns empty array if arrays have the same elements', () => { + expect(arrayDiff([1, 2], [1, 2])).toEqual([]); + }); + }); + + describe('caseInseFsitiveSort', () => { + it('sorts objects by string property case-insensitively', () => { + const arr = [{n: 'b'}, {n: 'A'}, {n: 'c'}]; + arr.sort(caseInsensitiveSort('n')); + expect(arr.map(i => i.n)).toEqual(['A', 'b', 'c']); + }); + }); + + describe('findByProp', () => { + it('filters objects by property value', () => { + const arr = [{name: 'foo'}, {name: 'bar'}]; + const found = arr.filter(findByProp('name', 'foo')); + expect(found).toEqual([{name: 'foo'}]); }); }); describe('flattenArr', () => { - test('flat array', () => expect(flattenArr([1, 2])).toStrictEqual([1, 2])); - test('2D array', () => expect(flattenArr([[1, 2], [3, 4]])).toStrictEqual([1, 2, 3, 4])); - test('3D array', () => expect(flattenArr([[[1, 2], [3, 4]], [[5, 6], [7, 8]]])).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8])); - test('mixed array', () => expect(flattenArr([1, 2, [3, 4], [[5, 6], [7, 8]]])).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8])); + it('flattens deeply nested arrays', () => { + const arr = [1, [2, [3, [4]], 5], 6]; + expect(flattenArr(arr)).toEqual([1, 2, 3, 4, 5, 6]); + }); + it('flattens flat array as-is', () => { + expect(flattenArr([1, 2, 3])).toEqual([1, 2, 3]); + }); }); describe('sortByProp', () => { - test('random letters', () => { - let unsorted: any = Array(100).fill(null) - .map(() => String.fromCharCode(Math.round(Math.random() * 25) + 97)); - const sorted = unsorted.sort((a: any, b: any) => { - if(a > b) return 1; - if(a < b) return -1; - return 0; - }).map((l: any) => ({a: l})); - unsorted = unsorted.map((l: any) => ({a: l})); - expect(unsorted.sort(sortByProp('a'))).toStrictEqual(sorted); + it('sorts by numeric property', () => { + const arr = [{a: 3}, {a: 1}, {a: 2}]; + arr.sort(sortByProp('a')); + expect(arr.map(i => i.a)).toEqual([1, 2, 3]); }); - test('random letters reversed', () => { - let unsorted: any = Array(100).fill(null) - .map(() => String.fromCharCode(Math.round(Math.random() * 25) + 97)); - const sorted = unsorted.sort((a: any, b: any) => { - if(a > b) return -1; - if(a < b) return 1; - return 0; - }).map((n: any) => ({a: n})); - unsorted = unsorted.map((n: any) => ({a: n})); - expect(unsorted.sort(sortByProp('a', true))).toStrictEqual(sorted); - }); - test('random numbers', () => { - let unsorted: any = Array(100).fill(null).map(() => Math.round(Math.random() * 100)); - const sorted = unsorted.sort((a: any, b: any) => a - b).map((n: any) => ({a: n})); - unsorted = unsorted.map((n: any) => ({a: n})); - expect(unsorted.sort(sortByProp('a'))).toStrictEqual(sorted); - }); - test('random numbers reversed', () => { - let unsorted: any = Array(100).fill(null).map(() => Math.round(Math.random() * 100)); - const sorted = unsorted.sort((a: any, b: any) => b - a).map((n: any) => ({a: n})); - unsorted = unsorted.map((n: any) => ({a: n})); - expect(unsorted.sort(sortByProp('a', true))).toStrictEqual(sorted); + it('sorts by string property reversed', () => { + const arr = [{a: 'apple'}, {a: 'banana'}, {a: 'pear'}]; + arr.sort(sortByProp('a', true)); + expect(arr.map(i => i.a)).toEqual(['pear', 'banana', 'apple']); }); }); - describe('caseInsensitiveSort', () => { - test('non-string property', () => { - const unsorted: any = [{a: 'Apple', b: 123}, {a: 'Carrot', b: 789}, {a: 'banana', b: 456}]; - const sorted: any = unsorted.map((u: any) => ({...u})); - expect(unsorted.sort(caseInsensitiveSort('b'))).toStrictEqual(sorted); + describe('makeUnique', () => { + it('removes duplicate primitives', () => { + const arr = [1, 2, 2, 3, 1]; + expect(makeUnique(arr)).toEqual([1, 2, 3]); }); - test('simple strings', () => { - const unsorted: any = [{a: 'Apple'}, {a: 'Carrot'}, {a: 'banana'}]; - const sorted: any = unsorted.sort((first: any, second: any) => { - return first.a.toLowerCase().localeCompare(second.a.toLowerCase()); - }).map((u: any) => ({...u})); - expect(unsorted.sort(caseInsensitiveSort('a'))).toStrictEqual(sorted); + it('removes duplicate objects', () => { + const obj = {a: 1}; + const arr = [obj, obj, {a: 1}]; + expect(makeUnique(arr)).toHaveLength(1); }); - test('alphanumeric strings', () => { - const unsorted: any = [{a: '4pple'}, {a: 'Carrot'}, {a: 'b4n4n4'}]; - const sorted: any = unsorted.sort((first: any, second: any) => { - return first.a.toLowerCase().localeCompare(second.a.toLowerCase()); - }).map((u: any) => ({...u})); - expect(unsorted.sort(caseInsensitiveSort('a'))).toStrictEqual(sorted); + }); + + describe('makeArray', () => { + it('wraps non-arrays in array', () => { + expect(makeArray(1)).toEqual([1]); + }); + it('returns array as-is', () => { + expect(makeArray([1, 2])).toEqual([1, 2]); }); }); }); diff --git a/tests/aset.spec.ts b/tests/aset.spec.ts new file mode 100644 index 0000000..78d90fa --- /dev/null +++ b/tests/aset.spec.ts @@ -0,0 +1,148 @@ +import {ASet} from '../src'; + +describe('ASet', () => { + describe('constructor', () => { + it('should create a set with unique elements', () => { + const set = new ASet([1, 2, 2, 3]); + expect(set.size).toBe(3); + expect(set.sort()).toEqual([1, 2, 3]); + }); + + it('should create an empty set by default', () => { + const set = new ASet(); + expect(set.size).toBe(0); + }); + }); + + describe('add', () => { + it('should add unique elements', () => { + const set = new ASet([1]); + set.add(2, 3, 1); + expect(set.sort()).toEqual([1, 2, 3]); + }); + + it('should return this', () => { + const set = new ASet(); + expect(set.add(1)).toBe(set); + }); + }); + + describe('clear', () => { + it('should remove all elements', () => { + const set = new ASet([1, 2]); + set.clear(); + expect(set.size).toBe(0); + }); + + it('should return this', () => { + const set = new ASet([1]); + expect(set.clear()).toBe(set); + }); + }); + + describe('delete', () => { + it('should remove specified elements', () => { + const set = new ASet([1, 2, 3]); + set.delete(2, 4); + expect(set.sort()).toEqual([1, 3]); + }); + + it('should return this', () => { + const set = new ASet([1]); + expect(set.delete(1)).toBe(set); + }); + }); + + describe('difference', () => { + it('should return elements unique to this set', () => { + const setA = new ASet([1, 2, 3]); + const setB = new ASet([2, 4]); + expect(setA.difference(setB).sort()).toEqual([1, 3]); + expect(setB.difference(setA).sort()).toEqual([4]); + }); + }); + + describe('has', () => { + it('should check if element exists in set', () => { + const set = new ASet([1, 2]); + expect(set.has(1)).toBe(true); + expect(set.has(99)).toBe(false); + }); + }); + + describe('indexOf', () => { + it('should return correct index for primitive and object', () => { + const set = new ASet([{a: 1}, {b: 2}]); + expect(set.indexOf({a: 1})).toBe(0); + expect(set.indexOf({missing: 1})).toBe(-1); + + const numbers = new ASet([1, 2, 3]); + expect(numbers.indexOf(2)).toBe(1); + expect(numbers.indexOf(10)).toBe(-1); + }); + }); + + describe('intersection', () => { + it('should return elements common to both sets', () => { + const setA = new ASet([1, 2, 3]); + const setB = new ASet([2, 3, 4]); + expect(setA.intersection(setB).sort()).toEqual([2, 3]); + }); + }); + + describe('isDisjointFrom', () => { + it('should check for no common elements', () => { + const setA = new ASet([1, 2]); + const setB = new ASet([3, 4]); + const setC = new ASet([2, 3]); + expect(setA.isDisjointFrom(setB)).toBe(true); + expect(setA.isDisjointFrom(setC)).toBe(false); + }); + }); + + describe('isSubsetOf', () => { + it('should check if set is subset', () => { + const a = new ASet([1, 2]); + const b = new ASet([1, 2, 3]); + expect(a.isSubsetOf(b)).toBe(true); + expect(b.isSubsetOf(a)).toBe(false); + }); + }); + + describe('isSuperset', () => { + it('should check if set is superset', () => { + const a = new ASet([1, 2, 3]); + const b = new ASet([1, 2]); + expect(a.isSuperset(b)).toBe(true); + expect(b.isSuperset(a)).toBe(false); + }); + }); + + describe('symmetricDifference', () => { + it('should return elements only in one set (XOR)', () => { + const a = new ASet([1, 2, 3]); + const b = new ASet([3, 4]); + expect(a.symmetricDifference(b).sort()).toEqual([1, 2, 4]); + }); + }); + + describe('union', () => { + it('should return union of two sets', () => { + const a = new ASet([1, 2]); + const b = new ASet([2, 3]); + expect(a.union(b).sort()).toEqual([1, 2, 3]); + }); + + it('should work with arrays', () => { + const a = new ASet([1]); + expect(a.union([2, 1, 3]).sort()).toEqual([1, 2, 3]); + }); + }); + + describe('size', () => { + it('should return number of unique elements', () => { + const set = new ASet([1, 1, 2, 3, 3]); + expect(set.size).toBe(3); + }); + }); +}); diff --git a/tests/cache.spec.ts b/tests/cache.spec.ts new file mode 100644 index 0000000..e4c8385 --- /dev/null +++ b/tests/cache.spec.ts @@ -0,0 +1,109 @@ +import {Cache} from '../src'; + +describe('Cache', () => { + type TestItem = { id: string; value: string; }; + + let cache: Cache; + let storageMock: Storage; + let storageGetItemSpy: jest.SpyInstance; + let storageSetItemSpy: jest.SpyInstance; + + beforeEach(() => { + storageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + key: jest.fn(), + length: 0, + }; + storageGetItemSpy = jest.spyOn(storageMock, 'getItem'); + storageSetItemSpy = jest.spyOn(storageMock, 'setItem'); + cache = new Cache('id', {storage: storageMock, storageKey: 'cache'}); + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + test('should add and get an item', () => { + const item = {id: '1', value: 'a'}; + cache.add(item); + expect(cache.get('1')).toEqual(item); + }); + + test('should not get an expired item when expired option not set', () => { + const item = {id: '1', value: 'a'}; + cache.set('1', item); + cache.options.expiryPolicy = 'keep'; + cache.expire('1'); + expect(cache.get('1')).toBeNull(); + expect(cache.get('1', true)).toEqual({...item, _expired: true}); + }); + + test('should set and get via property access (proxy)', () => { + (cache as any)['2'] = {id: '2', value: 'b'}; + expect((cache as any)['2']).toEqual({id: '2', value: 'b'}); + }); + + test('should remove an item', () => { + cache.set('1', {id: '1', value: 'a'}); + cache.delete('1'); + expect(cache.get('1')).toBeNull(); + expect(storageSetItemSpy).toHaveBeenCalled(); + }); + + test('should clear the cache', () => { + cache.add({id: '1', value: 'a'}); + cache.clear(); + expect(cache.get('1')).toBeNull(); + expect(cache.complete).toBe(false); + }); + + test('should add multiple items and mark complete', () => { + const rows = [ + {id: 'a', value: '1'}, + {id: 'b', value: '2'}, + ]; + cache.addAll(rows); + expect(cache.all().length).toBe(2); + expect(cache.complete).toBe(true); + }); + + test('should return all, keys, entries, and map', () => { + cache.add({id: '1', value: 'a'}); + cache.add({id: '2', value: 'b'}); + expect(cache.all().length).toBe(2); + expect(cache.keys().sort()).toEqual(['1', '2']); + expect(cache.entries().length).toBe(2); + expect(Object.keys(cache.map())).toContain('1'); + expect(Object.keys(cache.map())).toContain('2'); + }); + + // test('should expire/delete items after TTL', () => { + // jest.useFakeTimers(); + // cache = new Cache('id', {ttl: 0.1}); + // cache.add({id: '3', value: 'x'}); + // jest.advanceTimersByTime(250); + // expect(cache.get('3')).toBeNull(); + // }); + + test('should persist and restore from storage', () => { + (storageMock.getItem as jest.Mock).mockReturnValueOnce(JSON.stringify({a: {id: 'a', value: 'from-storage'}})); + const c = new Cache('id', {storage: storageMock, storageKey: 'cache'}); + expect(c.get('a')).toEqual({id: 'a', value: 'from-storage'}); + }); + + test('should handle expiryPolicy "delete"', () => { + cache.options.expiryPolicy = 'delete'; + cache.add({id: 'k1', value: 'KeepMe'}); + cache.expire('k1'); + expect(cache.get('k1', true)).toBeNull(); + }); + + test('should handle expiryPolicy "keep"', () => { + cache.options.expiryPolicy = 'keep'; + cache.add({id: 'k1', value: 'KeepMe'}); + cache.expire('k1'); + expect(cache.get('k1')).toBeNull(); + expect(cache.get('k1', true)?._expired).toBe(true); + }); +}); diff --git a/tests/color.spec.ts b/tests/color.spec.ts new file mode 100644 index 0000000..aed8fad --- /dev/null +++ b/tests/color.spec.ts @@ -0,0 +1,40 @@ +import {contrast} from '../src'; + +describe('contrast', () => { + it('should return "black" for white background', () => { + expect(contrast('ffffff')).toBe('black'); + expect(contrast('#ffffff'.replace('#', ''))).toBe('black'); // simulate trimmed hash + }); + + it('should return "white" for black background', () => { + expect(contrast('000000')).toBe('white'); + }); + + it('should return "white" for a dark color', () => { + expect(contrast('123456')).toBe('white'); + expect(contrast('222222')).toBe('white'); + }); + + it('should return "black" for a light color', () => { + expect(contrast('ffff99')).toBe('black'); + expect(contrast('cccccc')).toBe('black'); + }); + + it('should handle short hex color codes (3 chars)', () => { + expect(contrast('fff')).toBe('black'); + expect(contrast('000')).toBe('white'); + }); + + it('should return "black" for invalid input', () => { + expect(contrast('')).toBe('black'); + expect(contrast('zzzzzz')).toBe('black'); + expect(contrast('not-a-color')).toBe('black'); + expect(contrast(undefined as unknown as string)).toBe('black'); + expect(contrast(null as unknown as string)).toBe('black'); + }); + + it('should handle hex codes with hash prefix if removed', () => { + expect(contrast('ededed')).toBe('black'); + expect(contrast('343434')).toBe('white'); + }); +}); diff --git a/tests/csv.spec.ts b/tests/csv.spec.ts new file mode 100644 index 0000000..871053f --- /dev/null +++ b/tests/csv.spec.ts @@ -0,0 +1,90 @@ +import {fromCsv, toCsv} from '../src'; + +describe('CSV Utilities', () => { + describe('fromCsv', () => { + it('parses CSV with headers', () => { + const input = `name,age,city +John,30,New York +Jane,25,Los Angeles`; + const expected = [ + {name: 'John', age: '30', city: 'New York'}, + {name: 'Jane', age: '25', city: 'Los Angeles'}, + ]; + expect(fromCsv(input)).toEqual(expected); + }); + + it('parses CSV without headers', () => { + const input = `apple,red,1 +banana,yellow,2`; + const expected = [ + {A: 'apple', B: 'red', C: '1'}, + {A: 'banana', B: 'yellow', C: '2'}, + ]; + expect(fromCsv(input, false)).toEqual(expected); + }); + + it('handles quoted fields and commas', () => { + const input = `name,description +"Widget, Large","A large, useful widget" +Gadget,"A ""versatile"" gadget"`; + const expected = [ + {name: 'Widget, Large', description: 'A large, useful widget'}, + {name: 'Gadget', description: 'A "versatile" gadget'}, + ]; + expect(fromCsv(input)).toEqual(expected); + }); + + it('handles empty fields', () => { + const input = `id,name,score +1,Tom,97 +2,,89 +3,Alice,`; + const expected = [ + {id: '1', name: 'Tom', score: '97'}, + {id: '2', name: '', score: '89'}, + {id: '3', name: 'Alice', score: ''}, + ]; + expect(fromCsv(input)).toEqual(expected); + }); + }); + + describe('toCsv', () => { + it('converts array of objects to CSV', () => { + const arr = [ + {name: 'John', age: 30, city: 'New York'}, + {name: 'Jane', age: 25, city: 'Los Angeles'}, + ]; + const csv = toCsv(arr); + expect(csv).toContain('name,age,city'); + expect(csv).toContain('John,30,New York'); + expect(csv).toContain('Jane,25,Los Angeles'); + }); + + it('quotes fields with commas and quotes', () => { + const arr = [ + {val: 'Comma, included', remark: 'needs, quotes'}, + {val: 'Quote "double"', remark: 'embedded "quotes"'}, + ]; + const csv = toCsv(arr); + expect(csv).toContain('"Comma, included","needs, quotes"'); + expect(csv).toContain('"Quote ""double""","embedded ""quotes"""'); + }); + + it('handles nested objects when flatten = true', () => { + const arr = [ + {id: 1, info: {name: 'Alice', age: 20}}, + {id: 2, info: {name: 'Bob', age: 22}} + ]; + const csv = toCsv(arr, true); + expect(csv).toMatch(/id,info\.name,info\.age/); + expect(csv).toMatch(/1,Alice,20/); + expect(csv).toMatch(/2,Bob,22/); + }); + + it('handles objects with array fields', () => { + const arr = [{name: 'Joe', tags: ['a', 'b']}]; + const csv = toCsv(arr); + expect(csv).toContain('Joe,"[""a"",""b""]"'); + }); + }); +}); diff --git a/tests/emitter.spec.ts b/tests/emitter.spec.ts new file mode 100644 index 0000000..19fb6e4 --- /dev/null +++ b/tests/emitter.spec.ts @@ -0,0 +1,118 @@ +import {TypedEmitter} from '../src'; + +describe('TypedEmitter', () => { + describe('Instance', () => { + type Events = { + foo: (data: string) => void; + bar: (x: number, y: number) => void; + '*': (event: string, ...args: any[]) => void; + }; + let emitter: TypedEmitter; + + beforeEach(() => { + emitter = new TypedEmitter(); + }); + + it('calls the correct listener on emit', () => { + const fooHandler = jest.fn(); + emitter.on('foo', fooHandler); + + emitter.emit('foo', 'hello'); + expect(fooHandler).toHaveBeenCalledWith('hello'); + }); + + it('does NOT call listener after off', () => { + const fooHandler = jest.fn(); + emitter.on('foo', fooHandler); + emitter.off('foo', fooHandler); + emitter.emit('foo', 'test'); + expect(fooHandler).not.toHaveBeenCalled(); + }); + + it('returns unsubscribe function that removes handler', () => { + const handler = jest.fn(); + const unsubscribe = emitter.on('foo', handler); + unsubscribe(); + emitter.emit('foo', 'x'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('calls wildcard listener for all events', () => { + const wildcard = jest.fn(); + emitter.on('*', wildcard); + + emitter.emit('foo', 'data'); + emitter.emit('bar', 1, 2); + + expect(wildcard).toHaveBeenCalledWith('foo', 'data'); + expect(wildcard).toHaveBeenCalledWith('bar', 1, 2); + }); + + it('once() resolves with argument and auto-unsubscribes', async () => { + const p = emitter.once('foo'); + emitter.emit('foo', 'only-once'); + expect(await p).toBe('only-once'); + + // no more handlers + const cb = jest.fn(); + emitter.on('foo', cb); + emitter.emit('foo', 'again'); + expect(cb).toHaveBeenCalledWith('again'); + }); + + it('once() calls optional listener and Promise resolves', async () => { + const listener = jest.fn(); + const oncePromise = emitter.once('bar', listener); + + emitter.emit('bar', 1, 2); + + expect(listener).toHaveBeenCalledWith(1, 2); + expect(await oncePromise).toEqual([1, 2]); + }); + }); + + describe('Static', () => { + beforeEach(() => { + // Clear static listeners between tests + (TypedEmitter as any).listeners = {}; + }); + + it('calls static listeners with emit', () => { + const spy = jest.fn(); + TypedEmitter.on('event', spy); + + TypedEmitter.emit('event', 1, 'a'); + expect(spy).toHaveBeenCalledWith(1, 'a'); + }); + + it('wildcard static listeners receive all event types', () => { + const spy = jest.fn(); + TypedEmitter.on('*', spy); + + TypedEmitter.emit('xy', 123); + expect(spy).toHaveBeenCalledWith('xy', 123); + }); + + it('only calls listener once with once()', async () => { + const handler = jest.fn(); + const p = TypedEmitter.once('ping', handler); + + TypedEmitter.emit('ping', 'pong'); + + expect(handler).toHaveBeenCalledWith('pong'); + await expect(p).resolves.toBe('pong'); + + handler.mockClear(); + TypedEmitter.emit('ping', 'other'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('removes static listener with off', () => { + const h = jest.fn(); + TypedEmitter.on('offevent', h); + TypedEmitter.off('offevent', h); + TypedEmitter.emit('offevent', 42); + expect(h).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/errors.spec.ts b/tests/errors.spec.ts new file mode 100644 index 0000000..73a221a --- /dev/null +++ b/tests/errors.spec.ts @@ -0,0 +1,112 @@ +import { + CustomError, + BadRequestError, + UnauthorizedError, + PaymentRequiredError, + ForbiddenError, + NotFoundError, + MethodNotAllowedError, + NotAcceptableError, + InternalServerError, + NotImplementedError, + BadGatewayError, + ServiceUnavailableError, + GatewayTimeoutError, + errorFromCode +} from '../src'; + +describe('CustomError Hierarchy', () => { + it('CustomError basic properties and code getter/setter', () => { + const err = new CustomError('Test', 501); + expect(err.message).toBe('Test'); + expect(err.code).toBe(501); + err.code = 404; + expect(err.code).toBe(404); + // default code if not provided + const noCodeError = new CustomError('No code'); + expect(noCodeError.code).toBe(500); + }); + + it('CustomError static from method copies properties and stack', () => { + const orig: any = new Error('oops'); + orig.code = 402; + orig.stack = 'FAKE_STACK'; + const custom = CustomError.from(orig); + expect(custom).toBeInstanceOf(CustomError); + expect(custom.message).toBe('oops'); + expect(custom.code).toBe(500); + expect(custom.stack).toBe('FAKE_STACK'); + }); + + it('CustomError instanceof works', () => { + expect(CustomError.instanceof(new CustomError())).toBe(true); + expect(CustomError.instanceof(new Error())).toBe(false); + }); + + it('CustomError toString returns message', () => { + const err = new CustomError('foo'); + expect(err.toString()).toBe('foo'); + }); + + const cases = [ + [BadRequestError, 400, 'Bad Request'], + [UnauthorizedError, 401, 'Unauthorized'], + [PaymentRequiredError, 402, 'Payment Required'], + [ForbiddenError, 403, 'Forbidden'], + [NotFoundError, 404, 'Not Found'], + [MethodNotAllowedError, 405, 'Method Not Allowed'], + [NotAcceptableError, 406, 'Not Acceptable'], + [InternalServerError, 500, 'Internal Server Error'], + [NotImplementedError, 501, 'Not Implemented'], + [BadGatewayError, 502, 'Bad Gateway'], + [ServiceUnavailableError, 503, 'Service Unavailable'], + [GatewayTimeoutError, 504, 'Gateway Timeout'], + ] as const; + + describe.each(cases)( + '%p (code=%i, defaultMessage="%s")', + (ErrClass, code, defMsg) => { + it('has static code, default message, and instanceof', () => { + const e = new ErrClass(); + expect(e).toBeInstanceOf(ErrClass); + expect(e.code).toBe(code); + expect(e.message).toBe(defMsg); + expect(ErrClass.instanceof(e)).toBe(true); + }); + + it('supports custom messages', () => { + const msg = 'Custom msg'; + const e = new ErrClass(msg); + expect(e.message).toBe(msg); + }); + } + ); + + describe('errorFromCode', () => { + it.each(cases)( + 'returns %p for code %i', + (ErrClass, code, defMsg) => { + const err = errorFromCode(code); + expect(err).toBeInstanceOf(ErrClass); + expect(err.code).toBe(code); + expect(err.message).toBe(defMsg); + } + ); + it('overrides message if provided', () => { + const err = errorFromCode(404, 'Nope'); + expect(err).toBeInstanceOf(NotFoundError); + expect(err.message).toBe('Nope'); + }); + it('fallbacks to CustomError for unknown codes', () => { + const err = errorFromCode(999, 'xyz'); + expect(err).toBeInstanceOf(CustomError); + expect(err.code).toBe(999); + expect(err.message).toBe('xyz'); + }); + it('handles missing message gracefully', () => { + const err = errorFromCode(555); + expect(err).toBeInstanceOf(CustomError); + expect(err.code).toBe(555); + }); + }); +}); diff --git a/tests/jwt.spec.ts b/tests/jwt.spec.ts new file mode 100644 index 0000000..6c09239 --- /dev/null +++ b/tests/jwt.spec.ts @@ -0,0 +1,61 @@ +import { createJwt, decodeJwt } from '../src'; + +describe('JWT Utilities', () => { + describe('createJwt', () => { + it('should create a valid JWT string with default signature', () => { + const payload = { foo: 'bar', num: 123 }; + const jwt = createJwt(payload); + const parts = jwt.split('.'); + expect(parts).toHaveLength(3); + + // Header should decode to HS256 + JWT + const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString()); + expect(header).toEqual({ alg: "HS256", typ: "JWT" }); + + // Body should match the payload + const body = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); + expect(body).toEqual(payload); + + // Signature should be 'unsigned' + expect(parts[2]).toBe('unsigned'); + }); + + it('should allow custom signature', () => { + const jwt = createJwt({ test: 1 }, 'mysignature'); + expect(jwt.split('.')[2]).toBe('mysignature'); + }); + }); + + describe('decodeJwt', () => { + it('should decode a JWT payload', () => { + const payload = { user: 'alice', age: 30 }; + const jwt = createJwt(payload); + const decoded = decodeJwt(jwt); + expect(decoded).toEqual(payload); + }); + + it('should decode payload with different types', () => { + const payload = { arr: [1,2,3], flag: true, val: null }; + const jwt = createJwt(payload); + const decoded = decodeJwt(jwt); + expect(decoded).toEqual(payload); + }); + + it('should throw or return null for malformed tokens', () => { + // Not enough parts + expect(() => decodeJwt('foo.bar')).toThrow(); + // Bad base64 + expect(() => decodeJwt('a.b@d.c')).toThrow(); + }); + + it('should decode JWT even if signature is missing', () => { + // Two-part JWT (not standard, but let's see what happens) + const payload = { ok: true }; + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString('base64url'); + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const jwt = `${header}.${body}`; + const decoded = decodeJwt(jwt + '.'); + expect(decoded).toEqual(payload); + }); + }); +}); diff --git a/tests/math.spec.ts b/tests/math.spec.ts new file mode 100644 index 0000000..afe6090 --- /dev/null +++ b/tests/math.spec.ts @@ -0,0 +1,51 @@ +import { dec2Frac, fracToDec } from '../src'; + +describe('Math Utilities', () => { + describe('dec2Frac', () => { + it('should convert decimal to fraction with whole and remainder', () => { + expect(dec2Frac(1.25)).toBe('1 1/4'); + expect(dec2Frac(2.5)).toBe('2 1/2'); + expect(dec2Frac(3.75)).toBe('3 3/4'); + }); + + it('should convert integer to fraction with denominator', () => { + expect(dec2Frac(4)).toBe('4'); + expect(dec2Frac(0)).toBe('0'); + }); + + it('should convert proper fraction (less than 1)', () => { + expect(dec2Frac(0.75)).toBe('3/4'); + expect(dec2Frac(0.5)).toBe('1/2'); + expect(dec2Frac(0.1)).toBe('1/10'); + }); + + it('should handle repeating decimals gracefully', () => { + expect(dec2Frac(0.333333)).toBe('1/3'); + expect(dec2Frac(0.666666)).toBe('2/3'); + }); + }); + + describe('fracToDec', () => { + it('should convert mixed fraction to decimal', () => { + expect(fracToDec('1 1/4')).toBeCloseTo(1.25); + expect(fracToDec('2 1/2')).toBeCloseTo(2.5); + expect(fracToDec('3 3/4')).toBeCloseTo(3.75); + }); + + it('should convert fraction without whole part to decimal', () => { + expect(fracToDec('3/4')).toBeCloseTo(0.75); + expect(fracToDec('1/2')).toBeCloseTo(0.5); + expect(fracToDec('1/10')).toBeCloseTo(0.1); + }); + + it('should convert whole number fraction', () => { + expect(fracToDec('4 0/1')).toBeCloseTo(4); + expect(fracToDec('0/1')).toBeCloseTo(0); + }); + + it('should handle zero correctly', () => { + expect(fracToDec('0/1')).toBeCloseTo(0); + expect(fracToDec('0 0/1')).toBeCloseTo(0); + }); + }); +}); diff --git a/tests/misc.spec.ts b/tests/misc.spec.ts index f52014b..dec4995 100644 --- a/tests/misc.spec.ts +++ b/tests/misc.spec.ts @@ -1,18 +1,44 @@ -import {fn} from '../src'; +import {fn, gravatar, escapeRegex, md5} from '../src'; -describe('Miscellanies Utilities', () => { +describe('Misc Utilities', () => { describe('fn', () => { - test('async', async () => { - const test = {a: Math.random()}; - const resp = fn(test, 'return a;', true); - expect(resp instanceof Promise).toBeTruthy(); - expect(await resp).toEqual(test['a']); + it('should execute a stringified function with arguments', () => { + const result = fn({ x: 2, y: 3 }, 'return x + y;'); + expect(result).toBe(5); }); - test('sync', async () => { - const test = {a: Math.random()}; - const resp = fn(test, 'return a;3'); - expect(resp).toEqual(test['a']); + it('should execute an async function if async=true', async () => { + const asyncFn = 'return await Promise.resolve(x * y);'; + const result = await fn({ x: 3, y: 4 }, asyncFn, true); + expect(result).toBe(12); + }); + + it('should work with no arguments', () => { + const result = fn({}, 'return 42;'); + expect(result).toBe(42); + }); + }); + + describe('gravatar', () => { + it('should return empty string if email is falsy', () => { + expect(gravatar('')).toBe(''); + }); + it('should build correct gravatar url', () => { + const email = 'test@example.com'; + expect(gravatar(email)).toContain(`https://www.gravatar.com/avatar/${md5(email)}`); + }); + }); + + describe('escapeRegex', () => { + it('should escape all special regex characters', () => { + const special = '.*+?^${}()|[]\\'; + const escaped = escapeRegex(special); + expect(escaped).toBe('\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\'); + }); + it('should return original string if nothing to escape', () => { + const normal = 'abc123'; + const escaped = escapeRegex(normal); + expect(escaped).toBe('abc123'); }); }); }); diff --git a/tests/object.spec.ts b/tests/object.spec.ts index abd874d..9fa7159 100644 --- a/tests/object.spec.ts +++ b/tests/object.spec.ts @@ -1,89 +1,164 @@ -import {clean, deepCopy, dotNotation, flattenObj, includes, isEqual} from "../src"; - -describe('Object Utilities', () => { - const TEST_OBJECT = { - a: 1, - b: [ - [2, 3], - [4, 5] - ], - c: { - d: [ - [{e: 6, f: 7}] - ], - }, - g: {h: 8}, - i: () => 9 - }; +import { + clean, deepCopy, deepMerge, dotNotation, encodeQuery, flattenObj, formData, includes, isEqual, mixin, + JSONAttemptParse, JSONSerialize, JSONSanitize +} from '../src'; +describe('Object utilities', () => { describe('clean', () => { - test('remove null properties', () => { - const a = {a: 1, b: null, c: undefined}; - const final = {a: 1}; - expect(clean(a)).toEqual(final); + it('removes null values', () => { + const obj = { a: 1, b: null, c: undefined, d: 0 }; + expect(clean({ ...obj })).toEqual({ a: 1, c: undefined, d: 0 }); }); - test('remove undefined properties', () => { - const a = {a: 1, b: null, c: undefined}; - const final = {a: 1, b: null}; - expect(clean(a, true)).toEqual(final); + it('throws on null input', () => { + expect(() => clean(null as any)).toThrow(); + }); + it('removes undefined only when specified', () => { + const obj = { a: 1, b: undefined, c: null }; + expect(clean({ ...obj }, true)).toEqual({ a: 1, c: null }); + }); + it('works for arrays', () => { + expect(clean([1, null, 2, undefined, 3] as any)).toEqual([1, 2, 3]); + }); + }); + + describe('deepCopy', () => { + it('creates a deep copy', () => { + const obj = { a: { b: 2 } }; + const copy = deepCopy(obj); + expect(copy).toEqual(obj); + expect(copy).not.toBe(obj); + expect(copy.a).not.toBe(obj.a); + }); + }); + + describe('deepMerge', () => { + it('merges deeply nested objects', () => { + const tgt = { a: { b: 1 }, d: 7 }; + const src = { a: { c: 2 }, d: 8 }; + expect(deepMerge({ ...tgt }, src)).toEqual({ a: { b: 1, c: 2 }, d: 8 }); + }); + it('merges multiple sources', () => { + const t = { a: 1 }; + const s1 = { b: 2 }; + const s2 = { c: 3 }; + expect(deepMerge({ ...t }, s1, s2)).toEqual({ a: 1, b: 2, c: 3 }); }); }); describe('dotNotation', () => { - test('no object or properties', () => { - expect(dotNotation(undefined, 'z')).toStrictEqual(undefined); - expect(dotNotation(TEST_OBJECT, '')).toStrictEqual(undefined); + it('gets nested value', () => { + const obj = { a: { b: { c: 3 } } }; + expect(dotNotation(obj, 'a.b.c')).toBe(3); }); - test('invalid property', () => expect(dotNotation(TEST_OBJECT, 'z')).toBeUndefined()); - test('by property', () => expect(dotNotation(TEST_OBJECT, 'a')).toBe(TEST_OBJECT.a)); - test('by key', () => expect(dotNotation(TEST_OBJECT, '["a"]')).toBe(TEST_OBJECT['a'])); - test('by key (single quote)', () => expect(dotNotation(TEST_OBJECT, '[\'a\']')).toBe(TEST_OBJECT['a'])); - test('by key (double quote)', () => expect(dotNotation(TEST_OBJECT, '["a"]')).toBe(TEST_OBJECT['a'])); - test('by index', () => expect(dotNotation(TEST_OBJECT, 'b[0]')).toBe(TEST_OBJECT.b[0])); - test('by index (2d)', () => expect(dotNotation(TEST_OBJECT, 'b[1][1]')).toBe(TEST_OBJECT.b[1][1])); - test('everything combined', () => expect(dotNotation(TEST_OBJECT, 'c["d"][0][0].e')) - .toBe(TEST_OBJECT.c['d'][0][0].e)); - test('set value', () => { - const COPY = JSON.parse(JSON.stringify(TEST_OBJECT)); - dotNotation(COPY, 'c["d"][0][0].e', 'test'); - expect(COPY['c']['d'][0][0]['e']).toBe('test'); + it('sets nested value', () => { + const obj = { a: { b: { c: 3 } } }; + dotNotation(obj, 'a.b.c', 10); + expect(obj.a.b.c).toBe(10); }); - test('set new value', () => { - const COPY = JSON.parse(JSON.stringify(TEST_OBJECT)); - dotNotation(COPY, 'c.x.y.z', 'test'); - expect(COPY['c']['x']['y']['z']).toBe('test'); + it('returns undefined for non-existent path', () => { + expect(dotNotation({ a: 1 }, 'a.b.c')).toBeUndefined(); + }); + it('creates nested object when setting', () => { + const obj: any = {}; + dotNotation(obj, 'd.e.f', 5); + expect(obj.d.e.f).toBe(5); + }); + }); + + describe('encodeQuery', () => { + it('encodes simple objects', () => { + expect(encodeQuery({ a: 1, b: 'test' })).toBe('a=1&b=test'); + }); + it('handles special characters', () => { + expect(encodeQuery({ a: 'hello world' })).toBe('a=hello%20world'); + }); + }); + + describe('flattenObj', () => { + it('flattens nested objects', () => { + const obj = { a: { b: 2 }, c: 3 }; + expect(flattenObj(obj)).toEqual({ 'a.b': 2, c: 3 }); + }); + it('handles multiple nesting', () => { + const obj = { a: { b: { c: 4 } } }; + expect(flattenObj(obj)).toEqual({ 'a.b.c': 4 }); + }); + }); + + describe('formData', () => { + it('converts object to FormData', () => { + const obj = { a: '1', b: 'foo' }; + const fd = formData(obj); + expect(fd.get('a')).toBe('1'); + expect(fd.get('b')).toBe('foo'); }); }); describe('includes', () => { - test('simple', () => expect(includes(TEST_OBJECT, {a: 1})).toBeTruthy()); - test('nested', () => expect(includes(TEST_OBJECT, {g: {h: 8}})).toBeTruthy()); - test('array', () => expect(includes(TEST_OBJECT, {b: [[2]]})).toBeTruthy()); - test('nested array', () => expect(includes(TEST_OBJECT, {a: 1, c: {d: [[{e: 6}]]}})).toBeTruthy()); - test('wong nested array', () => expect(includes(TEST_OBJECT, {a: 1, c: {d: [{e: 7}]}})).toBeFalsy()); - test('wrong value', () => expect(includes(TEST_OBJECT, {a: 1, b: 2})).toBeFalsy()); - test('missing value', () => expect(includes(TEST_OBJECT, {a: 1, i: 10})).toBeFalsy()); + it('checks if all values included', () => { + expect(includes({ a: 2, b: 3 }, { a: 2 })).toBeTruthy(); + expect(includes({ a: 2, b: 3 }, { c: 1 })).toBeFalsy(); + }); + it('handles arrays of values', () => { + expect(includes([{ a: 1 }], [{ a: 1 }])).toBeTruthy(); + expect(includes([{ a: 1 }], [{ a: 2 }])).toBeFalsy(); + }); + it('allows missing when specified', () => { + expect(includes(undefined, { a: 2 }, true)).toBeTruthy(); + }); }); describe('isEqual', () => { - test('boolean equal', () => expect(isEqual(true, true)).toBeTruthy()); - test('boolean not-equal', () => expect(isEqual(true, false)).toBeFalsy()); - test('number equal', () => expect(isEqual(1, 1)).toBeTruthy()); - test('number not-equal', () => expect(isEqual(1, 0)).toBeFalsy()); - test('string equal', () => expect(isEqual('abc', 'abc')).toBeTruthy()); - test('string not-equal', () => expect(isEqual('abc', '')).toBeFalsy()); - test('array equal', () => expect(isEqual([true, 1, 'a'], [true, 1, 'a'])).toBeTruthy()); - test('array not-equal', () => expect(isEqual([true, 1, 'a'], [1])).toBeFalsy()); - test('object equal', () => expect(isEqual({a: 1, b: 2}, {a: 1, b: 2})).toBeTruthy()); - test('object not-equal', () => expect(isEqual({a: 1, b: 2}, {a: 1})).toBeFalsy()); - test('complex', () => expect(isEqual(TEST_OBJECT, TEST_OBJECT)).toBeTruthy()); + it('returns true for deeply equal objects', () => { + expect(isEqual({ a: 1, b: { c: 3 } }, { a: 1, b: { c: 3 } })).toBe(true); + }); + it('returns false for non-equal objects', () => { + expect(isEqual({ a: 1 }, { a: 2 })).toBe(false); + }); + it('compares functions by string', () => { + expect(isEqual(() => 1, () => 1)).toBe(true); + }); }); - describe('flattenObj', () => { - test('simple nested object', () => expect(flattenObj({a: {b: {c: 1}}})).toEqual({"a.b.c": 1})); - test('already flat object', () => expect(flattenObj(TEST_OBJECT['g'])).toEqual(TEST_OBJECT['g'])); - test('non-object input', () => expect(flattenObj(TEST_OBJECT['b'])).toBeUndefined()); - test('complex nested object', () => expect(flattenObj({a: 1, b: {c: 2}, d: {e: {f: {g: 3}}}})) - .toEqual({"a": 1, "b.c": 2, "d.e.f.g": 3})); + describe('mixin', () => { + it('merges prototypes', () => { + class A { foo() { return 1; } } + class B { bar() { return 2; } } + class C {} + mixin(C, [A, B]); + const c = new (C as any)(); + expect(c.foo()).toBe(1); + expect(c.bar()).toBe(2); + }); + }); + + describe('JSONAttemptParse', () => { + it('parses valid JSON', () => { + expect(JSONAttemptParse('{"a":1}')).toEqual({ a: 1 }); + }); + it('returns original string on error', () => { + expect(JSONAttemptParse('not json')).toBe('not json'); + }); + }); + + describe('JSONSerialize', () => { + it('serializes objects', () => { + expect(JSONSerialize({ a: 1 })).toBe(JSON.stringify({ a: 1 })); + }); + it('leaves primitives as is', () => { + expect(JSONSerialize('test')).toBe('test'); + expect(JSONSerialize(123)).toBe(123); + }); + }); + + describe('JSONSanitize', () => { + it('stringifies objects', () => { + expect(JSONSanitize({ a: 1 })).toBe(JSON.stringify({ a: 1 })); + }); + it('does not throw on circular refs', () => { + const obj: any = {}; + obj.self = obj; + expect(() => JSONSanitize(obj)).not.toThrow(); + }); }); }); diff --git a/tests/path-events.spec.ts b/tests/path-events.spec.ts index 03af2b9..62e6d8b 100644 --- a/tests/path-events.spec.ts +++ b/tests/path-events.spec.ts @@ -1,43 +1,200 @@ -import {PathEvent} from '../src'; +import {PathError, PathEvent, PathEventEmitter, PE, PES} from '../src'; describe('Path Events', () => { - describe('malformed', () => { - test('starting slash', async () => - expect(new PathEvent('/module').toString()).toEqual('module:*')); - test('trailing slash', async () => - expect(new PathEvent('module/').toString()).toEqual('module:*')); - test('double slash', async () => - expect(new PathEvent('module////path').toString()).toEqual('module/path:*')); + describe('PE', () => { + it('creates PathEvent from template string', () => { + const e = PE`users/system:cr`; + expect(e).toBeInstanceOf(PathEvent); + expect(e.fullPath).toBe('users/system'); + expect(e.create).toBe(true); + expect(e.read).toBe(true); + }); + + it('handles interpolation', () => { + const path = 'users/system'; + const meth = 'r'; + const e = PE`${path}:${meth}`; + expect(e.fullPath).toBe('users/system'); + expect(e.read).toBe(true); + }); }); - describe('methods', () => { - test('custom', async () => { - expect(new PathEvent('module:t').methods.includes('t')).toBeTruthy(); - expect(new PathEvent('module:t').methods.includes('z')).toBeFalsy(); + describe('PES', () => { + it('creates string for event', () => { + expect(PES`users/system:cr`).toBe('users/system:cr'); }); - test('create', async () => - expect(new PathEvent('module:crud').create).toBeTruthy()); - test('read', async () => - expect(new PathEvent('module:crud').read).toBeTruthy()); - test('update', async () => - expect(new PathEvent('module:crud').update).toBeTruthy()); - test('delete', async () => - expect(new PathEvent('module:crud').delete).toBeTruthy()); - test('none', async () => { - const event = new PathEvent('module:n'); - expect(event.none).toBeTruthy(); - expect(event.create).toBeFalsy(); - expect(event.read).toBeFalsy(); - expect(event.update).toBeFalsy(); - expect(event.delete).toBeFalsy() + }); + + describe('PathEvent', () => { + it('parses event string', () => { + const pe = new PathEvent('Users/system:cr'); + expect(pe.module).toBe('users'); + expect(pe.fullPath).toBe('users/system'); + expect(pe.name).toBe('system'); + expect(pe.create).toBe(true); + expect(pe.read).toBe(true); }); - test('wildcard', async () => { - const event = new PathEvent('module:*'); - expect(event.none).toBeFalsy(); - expect(event.create).toBeTruthy(); - expect(event.read).toBeTruthy(); - expect(event.update).toBeTruthy(); - expect(event.delete).toBeTruthy() + + it('parses wildcard', () => { + const pe = new PathEvent('*'); + expect(pe.all).toBe(true); + expect(pe.fullPath).toBe(''); + expect(pe.methods.has('*')).toBe(true); + }); + + it('parses none method', () => { + const pe = new PathEvent('users/system:n'); + expect(pe.none).toBe(true); + pe.none = false; + expect(pe.none).toBe(false); + }); + + it('setters for methods', () => { + const pe = new PathEvent('users/system:r'); + pe.create = true; + expect(pe.methods.has('c')).toBe(true); + pe.update = true; + expect(pe.methods.has('u')).toBe(true); + pe.delete = true; + expect(pe.methods.has('d')).toBe(true); + pe.read = false; + expect(pe.methods.has('r')).toBe(false); + }); + + it('combine merges longest path and methods', () => { + const a = new PathEvent('users/sys:cr'); + const b = new PathEvent('users/sys:u'); + const c = PathEvent.combine(a, b); + expect(c.fullPath).toBe('users/sys'); + expect(c.methods.has('c')).toBe(true); + expect(c.methods.has('r')).toBe(true); + expect(c.methods.has('u')).toBe(true); + }); + + it('combine stops at none', () => { + const a = new PathEvent('data/collection/doc:c'); + const b = new PathEvent('data/collection:r'); + const c = new PathEvent('data:n'); + const d = PathEvent.combine(a, b, c); + expect(d.fullPath).toBe(a.fullPath); + expect(d.create).toBe(true); + expect(d.read).toBe(true); + expect(d.update).toBe(false); + expect(d.none).toBe(false); + }); + + it('filter finds overlap by path and methods', () => { + const events = [ + new PathEvent('users/sys:cr'), + new PathEvent('users/sys:r'), + new PathEvent('files/sys:r') + ]; + const filtered = PathEvent.filter(events, 'users/sys:r'); + expect(filtered.length).toBe(2); + }); + + it('filter handles wildcard', () => { + const events = [ + new PathEvent('*'), + new PathEvent('users/sys:r') + ]; + const filtered = PathEvent.filter(events, 'users/sys:r'); + expect(filtered.length).toBe(2); + }); + + it('has returns true for overlapping', () => { + const events = [ + new PathEvent('users/sys:cr'), + ]; + expect(PathEvent.has(events, 'users/sys:r')).toBe(true); + expect(PathEvent.has(events, 'users/nope:r')).toBe(false); + }); + + it('hasAll returns true only if all overlap', () => { + const events = [ + new PathEvent('users/sys:cr'), + new PathEvent('users/sys:u'), + ]; + expect(PathEvent.hasAll(events, 'users/sys:c', 'users/sys:u')).toBe(true); + expect(PathEvent.hasAll(events, 'users/sys:c', 'users/sys:no')).toBe(false); + }); + + it('hasFatal throws if not found', () => { + expect(() => PathEvent.hasFatal('users/sys:r', 'users/other:r')).toThrow(PathError); + expect(() => PathEvent.hasFatal('users/sys:r', 'users/sys:r')).not.toThrow(); + }); + + it('hasAllFatal throws if missing', () => { + expect(() => PathEvent.hasAllFatal(['users/sys:r'], 'users/sys:r', 'users/sys:c')).toThrow(PathError); + }); + + it('toString creates correct event string', () => { + const s = PathEvent.toString('users/sys', ['c', 'r']); + expect(s).toBe('users/sys:cr'); + const pe = new PathEvent('users/sys:cr'); + expect(pe.toString()).toBe('users/sys:cr'); + }); + + it('filter instance filters as expected', () => { + const pe = new PathEvent('users/sys:r'); + const arr = ['users/sys:r', 'users/other:r']; + const filtered = pe.filter(arr); + expect(filtered[0].fullPath).toBe('users/sys'); + }); + }); + + describe('PathEventEmitter', () => { + it('wildcard', done => { + const emitter = new PathEventEmitter(); + emitter.on('*', (event) => { + expect(event.fullPath).toBe('system'); + done(); + }); + emitter.emit('system:c'); + }); + + it('scoped', done => { + const emitter = new PathEventEmitter('users'); + emitter.on(':cud', (event) => { + expect(event.fullPath).toBe('users/system'); + done(); + }); + emitter.emit('system:u'); + }); + + it('calls listener on matching emit', done => { + const emitter = new PathEventEmitter(); + const fn = jest.fn((event) => { + expect(event.fullPath).toBe('users/sys'); + done(); + }); + emitter.on('users/sys:r', fn); + emitter.emit('users/sys:r'); + }); + + it('off removes listener', () => { + const emitter = new PathEventEmitter(); + const fn = jest.fn(); + emitter.on('users/sys:r', fn); + emitter.off(fn); + emitter.emit('users/sys:r'); + expect(fn).not.toHaveBeenCalled(); + }); + + it('on returns unsubscribe function', () => { + const emitter = new PathEventEmitter(); + const fn = jest.fn(); + const unsub = emitter.on('users/sys:r', fn); + unsub(); + emitter.emit('users/sys:r'); + expect(fn).not.toHaveBeenCalled(); + }); + + it('emit supports prefix', () => { + const emitter = new PathEventEmitter('foo'); + emitter.once('*', (event) => + expect(event.fullPath).toBe('foo/bar')); + emitter.emit('bar:r'); }); }); }); diff --git a/tests/search.spec.ts b/tests/search.spec.ts new file mode 100644 index 0000000..3b13e03 --- /dev/null +++ b/tests/search.spec.ts @@ -0,0 +1,71 @@ +import {logicTest, search} from '../src'; + +const rows = [ + {id: 1, name: 'Alice', age: 30}, + {id: 2, name: 'Bob', age: 24}, + {id: 3, name: 'Carol', age: 30}, +]; + +describe('Search Utilities', () => { + describe('search', () => { + it('returns empty array for null rows', () => { + expect(search(null as any, 'test')).toEqual([]); + }); + + it('returns all rows if search is empty', () => { + expect(search(rows, '')).toEqual(rows); + }); + + it('filters based on a simple property string', () => { + expect(search(rows, 'Alice')).toEqual([rows[0]]); + }); + + it('filters using regex when regex=true', () => { + expect(search(rows, '^B', true)).toEqual([rows[1]]); + }); + + it('applies the transform function before filtering', () => { + const transform = (r: any) => ({...r, name: r.name.toLowerCase()}); + expect(search(rows, 'alice', false, transform)).toEqual([rows[0]]); + }); + + it('uses logicTest for non-regex search', () => { + expect(search(rows, 'age == 30')).toEqual([rows[0], rows[2]]); + expect(search(rows, 'id = 2')).toEqual([rows[1]]); + }); + + it('returns all if search is falsy and regex enabled', () => { + expect(search(rows, '', true)).toEqual(rows); + }); + }); + + describe('logicTest', () => { + const obj = {x: 10, y: 5, name: 'Alpha'}; + + it('handles equality and inequality', () => { + expect(logicTest(obj, 'x == 10')).toBe(true); + expect(logicTest(obj, 'y != 5')).toBe(false); + }); + + it('handles comparison operators', () => { + expect(logicTest(obj, 'x > 5')).toBe(true); + expect(logicTest(obj, 'y <= 10')).toBe(true); + expect(logicTest(obj, 'x < 5')).toBe(false); + }); + + it('supports case insensitive property search', () => { + expect(logicTest(obj, 'alpha')).toBeTruthy(); + expect(logicTest(obj, 'ALPHA')).toBeFalsy(); + }); + + it('handles logical AND/OR expressions', () => { + expect(logicTest(obj, 'x == 10 && y == 5')).toBe(true); + expect(logicTest(obj, 'x == 10 || y == 100')).toBe(true); + expect(logicTest(obj, 'x == 1 && y == 5')).toBe(false); + }); + + it('returns false for unsupported operators', () => { + expect(logicTest(obj, 'x === 10')).toBe(false); + }); + }); +}); diff --git a/tests/string.spec.ts b/tests/string.spec.ts index 0ce0c86..3a1835a 100644 --- a/tests/string.spec.ts +++ b/tests/string.spec.ts @@ -1,75 +1,167 @@ -import {matchAll, parseUrl, randomString, randomStringBuilder} from "../src"; - +import { + camelCase, + CHAR_LIST, + formatBytes, + formatPhoneNumber, + insertAt, kebabCase, + LETTER_LIST, matchAll, md5, + NUMBER_LIST, pad, parseUrl, pascalCase, randomHex, randomString, randomStringBuilder, snakeCase, strSplice, + SYMBOL_LIST +} from '../src'; describe('String Utilities', () => { - describe('randomString', () => { - test('length', () => expect(randomString(32).length).toStrictEqual(32)); - test('distribution', () => { - const charList = '123'; - const random = randomString(32, charList); - expect(random.split('').filter(c => c == '1').length).toBeGreaterThan(0); - expect(random.split('').filter(c => c == '2').length).toBeGreaterThan(0); - expect(random.split('').filter(c => c == '3').length).toBeGreaterThan(0); + test('LETTER_LIST, NUMBER_LIST, SYMBOL_LIST, CHAR_LIST', () => { + expect(LETTER_LIST).toBe('ABCDEFGHIJKLMNOPQRSTUVWXYZ'); + expect(NUMBER_LIST).toBe('0123456789'); + expect(SYMBOL_LIST).toContain('@'); + expect(CHAR_LIST).toContain('A'); + expect(CHAR_LIST).toContain('a'); + expect(CHAR_LIST).toContain('5'); + expect(CHAR_LIST).toContain('!'); + }); + + describe('camelCase', () => { + it('converts to camelCase', () => { + expect(camelCase('hello_world')).toBe('helloWorld'); + expect(camelCase('Hello world test')).toBe('helloWorldTest'); }); - test('binary', () => { - const randomByte = randomString(8, '01'); - expect(randomByte.split('').filter(c => c == '0').length).toBeGreaterThan(0); - expect(randomByte.split('').filter(c => c == '1').length).toBeGreaterThan(0); - expect(randomByte.length).toStrictEqual(8); + it('returns empty string if value is falsy', () => { + expect(camelCase()).toBe(''); + expect(camelCase('')).toBe(''); + }); + }); + + describe('formatBytes', () => { + it('correctly formats bytes', () => { + expect(formatBytes(0)).toBe('0 Bytes'); + expect(formatBytes(1024)).toBe('1 KB'); + expect(formatBytes(1024 * 1024)).toBe('1 MB'); + expect(formatBytes(1234, 1)).toBe('1.2 KB'); + }); + }); + + describe('formatPhoneNumber', () => { + it('formats plain phone numbers', () => { + expect(formatPhoneNumber('1234567890')).toBe('(123) 456-7890'); + expect(formatPhoneNumber('+11234567890')).toBe('+1 (123) 456-7890'); + expect(formatPhoneNumber('1 123 456 7890')).toBe('+1 (123) 456-7890'); + }); + it('throws for invalid phone strings', () => { + expect(() => formatPhoneNumber('abc')).toThrow(); + }); + }); + + describe('insertAt', () => { + it('inserts a string at a given index', () => { + expect(insertAt('Hello!', 'X', 5)).toBe('HelloX'); + }); + }); + + describe('kebabCase', () => { + it('converts to kebab-case', () => { + expect(kebabCase('HelloWorldTest')).toContain('-hello'); + expect(kebabCase('')).toBe(''); + }); + }); + + describe('pad', () => { + it('pads start by default', () => { + expect(pad('1', 2, '0')).toBe('01'); + }); + it('pads end if start is false', () => { + expect(pad('1', 3, '0', false)).toBe('100'); + }); + }); + + describe('pascalCase', () => { + it('converts to PascalCase', () => { + expect(pascalCase('hello_world')).toBe('HelloWorld'); + expect(pascalCase('')).toBe(''); + }); + }); + + describe('randomHex', () => { + it('creates a random hex string of correct length', () => { + expect(randomHex(8)).toHaveLength(8); + expect(/^[a-f0-9]{8}$/i.test(randomHex(8))).toBe(true); + }); + }); + + describe('randomString', () => { + it('creates a random string from CHAR_LIST of correct length', () => { + const s = randomString(10); + expect(s).toHaveLength(10); + // letters, numbers, symbols all included in CHAR_LIST + }); + it('uses provided pool', () => { + expect(['0','1']).toContain(randomString(1, '01')); }); }); describe('randomStringBuilder', () => { - test('length', () => { - const len = ~~(Math.random() * 32); - expect(randomStringBuilder(len, true).length).toStrictEqual(len); + it('creates with just letters', () => { + expect(/^[A-Z]+$/.test(randomStringBuilder(5, true, false, false))).toBe(true); }); - test('no length', () => { - expect(randomStringBuilder(0, true)).toStrictEqual(''); + it('creates with just numbers', () => { + expect(/^[0-9]+$/.test(randomStringBuilder(5, false, true, false))).toBe(true); }); - test('letters only', () => - expect(/^[a-zA-Z]{10}$/g.test(randomStringBuilder(10, true))).toBeTruthy()); - test('numbers only', () => - expect(/^[0-9]{10}$/g.test(randomStringBuilder(10, false, true))).toBeTruthy()); - test('symbols only', () => - expect(/^[^a-zA-Z0-9]{10}$/g.test(randomStringBuilder(10, false, false, true))).toBeTruthy()); - test('everything', () => { - const randomString = randomStringBuilder(30, true, true, true); - expect(/[a-zA-Z]/g.test(randomString)).toBeTruthy(); - expect(/[0-9]/g.test(randomString)).toBeTruthy(); - expect(/[^a-zA-Z0-9]/g.test(randomString)).toBeTruthy(); + it('creates with just symbols', () => { + expect(SYMBOL_LIST).toContain(randomStringBuilder(1, false, false, true)); + }); + it('throws if all false', () => { + expect(() => randomStringBuilder(5, false, false, false)).toThrow(); + }); + }); + + describe('snakeCase', () => { + it('converts to snake_case', () => { + expect(snakeCase('helloWorld')).toContain('hello_world'); + expect(snakeCase('')).toBe(''); + }); + }); + + describe('strSplice', () => { + it('splices string as expected', () => { + expect(strSplice('abcdef', 2, 2, 'ZZ')).toBe('abZZef'); + expect(strSplice('abcdef', 1, 0, 'Z')).toBe('aZbcdef'); }); - test('no pool', () => - expect(() => randomStringBuilder(10, false, false, false)).toThrow()); }); describe('matchAll', () => { - test('using string', () => expect(matchAll('fooBar fooBar FooBar', 'fooBar').length).toBe(2)); - test('using regex', () => expect(matchAll('fooBar fooBar FooBar', /fooBar/g).length).toBe(2)); - test('using malformed regex', () => expect(() => matchAll('fooBar fooBar FooBar', /fooBar/)).toThrow()); + it('returns expected matches', () => { + const matches = matchAll('a1 b2 c3', /\d/g); + expect(matches.length).toBe(3); + expect(matches[0][0]).toBe('1'); + }); + it('throws for non-global regex', () => { + expect(() => matchAll('abc', /a/)).toThrow(); + }); + it('accepts regex string', () => { + const matches = matchAll('a1a2', '\\d'); + expect(matches.length).toBe(2); + }); }); - describe('urlParser', () => { - test('localhost', () => { - const parsed = parseUrl('http://localhost:4200/some/path?q1=test1&q2=test2#frag'); - expect(parsed.protocol).toStrictEqual('http'); - expect(parsed.host).toStrictEqual('localhost:4200'); - expect(parsed.domain).toStrictEqual('localhost'); - expect(parsed.port).toStrictEqual(4200); - expect(parsed.path).toStrictEqual('/some/path'); - expect(parsed.query).toStrictEqual({q1: 'test1', q2: 'test2'}); - expect(parsed.fragment).toStrictEqual('frag'); + describe('parseUrl', () => { + it('parses a full url', () => { + const url = parseUrl('https://sub.example.com:8000/path?a=1&b=2#frag'); + expect(url.protocol).toBe('https'); + expect(url.subdomain).toBe('sub'); + expect(url.domain).toBe('example.com'); + expect(url.port).toBe(8000); + expect(url.path).toBe('/path'); + expect(url.query).toEqual({ a: '1', b: '2' }); + expect(url.fragment).toBe('frag'); }); + it('parses domain without subdomain', () => { + const url = parseUrl('https://example.com'); + expect(url.domain).toBe('example.com'); + }); + }); - test('subdomains', () => { - const parsed = parseUrl('https://sub.domain.example.com/some/path?q1=test1&q2=test2#frag'); - expect(parsed.protocol).toStrictEqual('https'); - expect(parsed.host).toStrictEqual('sub.domain.example.com'); - expect(parsed.domain).toStrictEqual('example.com'); - expect(parsed.subdomain).toStrictEqual('sub.domain'); - expect(parsed.path).toStrictEqual('/some/path'); - expect(parsed.query).toStrictEqual({q1: 'test1', q2: 'test2'}); - expect(parsed.fragment).toStrictEqual('frag'); + describe('md5', () => { + it('hashes string to hex', () => { + expect(md5('test')).toMatch(/^[a-f0-9]+$/i); }); }); }); diff --git a/tests/time.spec.ts b/tests/time.spec.ts index b9e2311..3b85c63 100644 --- a/tests/time.spec.ts +++ b/tests/time.spec.ts @@ -1,12 +1,108 @@ -import {sleep} from '../src'; +import {adjustedInterval, formatDate, instantInterval, sleep, sleepWhile, timeUntil} from '../src'; + +jest.useFakeTimers(); describe('Time Utilities', () => { + describe('adjustedInterval', () => { + it('calls callback at roughly correct intervals, considering execution time', async () => { + const cb = jest.fn(() => new Promise(res => setTimeout(res, 5))); + const stop = adjustedInterval(cb, 50); + + expect(cb).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(50); + expect(cb).toHaveBeenCalledTimes(2); + + await jest.advanceTimersByTimeAsync(100); + expect(cb).toHaveBeenCalledTimes(4); + + stop(); + await jest.advanceTimersByTimeAsync(100); + expect(cb).toHaveBeenCalledTimes(4); + }); + }); + + describe('formatDate', () => { + it('formats current date correctly with default format', () => { + const result = formatDate('YYYY-MM-DD', new Date('2023-01-15T10:30:30.000Z'), 0); + expect(result).toBe('2023-01-15'); + }); + + it('handles formatting for given timestamp', () => { + const timestamp = Date.UTC(2023, 1, 1, 18, 5, 5, 123); // Feb 1, 2023 18:05:05.123 UTC + const formatted = formatDate('YYYY MM DD HH mm ss SSS A Z', timestamp, 'UTC'); + expect(formatted).toMatch(/^2023 02 01 18 05 05 123 PM \+?0:00$/i); + }); + + it('throws for unknown timezone', () => { + expect(() => formatDate('YYYY', new Date(), '???')).toThrowError(/Unknown timezone/); + }); + + it('handles timezone by offset number', () => { + const dt = new Date('2020-01-01T00:00:00.000Z'); + const str = formatDate('HH:mm z', dt, 1); + expect(str).toMatch(/01:00/); + }); + + it('handles Do, MMMM, dddd tokens', () => { + const dt = new Date('2021-03-03T09:00:00Z'); + const result = formatDate('Do MMMM dddd', dt, 0); + expect(result).toMatch(/^3rd March Wednesday$/); + }); + }); + + describe('instantInterval', () => { + it('calls function immediately then at intervals', () => { + const cb = jest.fn(); + const id = instantInterval(cb, 1000); + expect(cb).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1000); + expect(cb).toHaveBeenCalledTimes(2); + + clearInterval(id); + }); + }); + describe('sleep', () => { - test('wait until', async () => { - const wait = ~~(Math.random() * 500); - const time = new Date().getTime(); - await sleep(wait); - expect(new Date().getTime()).toBeGreaterThanOrEqual(time + wait); + it('waits the given ms', async () => { + const time = Date.now(), wait = 100; + const promise = sleep(wait); + jest.advanceTimersByTime(wait); + await promise; + expect(Date.now()).toBeGreaterThanOrEqual(time + wait); + }); + }); + + describe('sleepWhile', () => { + it('resolves once condition is false', async () => { + const time = Date.now(), wait = 300; + let flag = true; + const promise = sleepWhile(() => flag, 100); + setTimeout(() => { flag = false; }, wait); + await jest.advanceTimersByTimeAsync(wait); + await promise; + expect(Date.now()).toBeGreaterThanOrEqual(time + wait); + }); + }); + + describe('timeUntil', () => { + it('returns milliseconds until given date', () => { + const now = Date.now(); + const future = now + 1000; + jest.spyOn(Date, 'now').mockReturnValue(now); + + const result = timeUntil(future); + expect(result).toBe(1000); + }); + + it('accepts Date object', () => { + const now = new Date(); + const t = new Date(now.getTime() + 450); + jest.spyOn(global, 'Date').mockImplementation(() => now as any); + + const result = timeUntil(t); + expect(result).toBe(450); }); }); }); diff --git a/tests/type.spec.ts b/tests/type.spec.ts new file mode 100644 index 0000000..486b3db --- /dev/null +++ b/tests/type.spec.ts @@ -0,0 +1,18 @@ +import {Writable} from '../src'; + +describe('Type Utilities', () => { + describe('Writable', () => { + it('should create a writable version of a readonly type', () => { + type ReadonlyPerson = { + readonly name: string; + readonly age: number; + }; + type WritablePerson = Writable; + // Typescript: WritablePerson's properties should not be readonly + const person: WritablePerson = { name: 'Alice', age: 40 }; + person.name = 'Bob'; // Should not error in TypeScript + person.age = 41; // Should not error in TypeScript + expect(person).toEqual({ name: 'Bob', age: 41 }); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d2cb5fc..4a8beca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "include": ["src"], "compilerOptions": { - "target": "ES2020", + "target": "ESNext", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ESNext", "DOM", "DOM.Iterable"],