Compare commits

..

No commits in common. "develop" and "0.24.1" have entirely different histories.

38 changed files with 452 additions and 2125 deletions

View File

@ -1,9 +1,104 @@
<html> <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>About Us | OurTrainingRoom</title>
<style>
body {
margin: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #fdfdfd;
color: #333;
line-height: 1.6;
}
header {
background: #004080;
color: #fff;
padding: 2rem 1rem;
text-align: center;
}
header h1 {
margin: 0;
font-size: 2.5rem;
}
main {
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
section {
margin-bottom: 2rem;
}
h2 {
color: #004080;
margin-bottom: 0.5rem;
}
ul {
padding-left: 1.25rem;
}
footer {
text-align: center;
font-size: 0.9rem;
color: #777;
padding: 2rem 1rem;
background: #f1f1f1;
margin-top: 4rem;
}
</style>
</head>
<body> <body>
<script type="module"> <header>
import {PES} from './dist/index.mjs'; <h1>About Us</h1>
<p>Empowering Learning Through Innovation</p>
</header>
console.log(PES`storage${'Test/Test'}:d`); <main>
</script> <section>
<p>
E-learning has evolved significantly since its inception. Today, there's a shift towards
blended learning services, integrating online activities with practical, real-world applications.
</p>
</section>
<section>
<h2>What We Do</h2>
<p>At <strong>OurTrainingRoom.com</strong>, we specialize in content management and professional development training tailored for:</p>
<ul>
<li>School Boards</li>
<li>Municipalities</li>
<li>Hospitals</li>
<li>Large Corporations</li>
</ul>
</section>
<section>
<h2>Our Roots</h2>
<p>
Our parent company, <strong>The Auxilium Group</strong>, is a leader in online data management.
The formation of OurTrainingRoom.com was a natural progression to deliver state-of-the-art front-end e-learning programs.
</p>
</section>
<section>
<h2>Our Approach</h2>
<p>
Built on principles of quality and continuous improvement, our diverse delivery range continues to grow.
We set new trends by enhancing our existing products and attentively listening to our clients and their employees.
This unique approach has solidified our position in the industry, making a substantial impact for our clients.
</p>
</section>
<section>
<h2>Have a Question?</h2>
<p>
We value your inquiries and are here to assist you. Please reach out with any questions or feedback.
</p>
</section>
</main>
<footer>
&copy; 2025 OurTrainingRoom.com. All rights reserved.
</footer>
</body> </body>
</html> </html>

View File

@ -1,6 +1,6 @@
{ {
"name": "@ztimson/utils", "name": "@ztimson/utils",
"version": "0.25.18", "version": "0.24.1",
"description": "Utility library", "description": "Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",
@ -26,12 +26,8 @@
"test:coverage": "npx jest --coverage", "test:coverage": "npx jest --coverage",
"watch": "npx vite build --watch" "watch": "npx vite build --watch"
}, },
"dependencies": {
"var-persist": "^1.0.1"
},
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"fake-indexeddb": "^6.0.1",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"ts-jest": "^29.1.2", "ts-jest": "^29.1.2",

View File

@ -72,11 +72,10 @@ export class ArgParser {
extras.push(arg); extras.push(arg);
continue; continue;
} }
const value = combined[1] != null ? combined [1] : const value = argDef.default === false ? true :
argDef.default === false ? true : argDef.default === true ? false :
argDef.default === true ? false : queue.splice(queue.findIndex(q => q[0] != '-'), 1)[0] ||
queue.splice(queue.findIndex(q => q[0] != '-'), 1)[0] || argDef.default;
argDef.default;
if(value == null) parsed['_error'].push(`Option missing value: ${argDef.name || combined[0]}`); if(value == null) parsed['_error'].push(`Option missing value: ${argDef.name || combined[0]}`);
parsed[argDef.name] = value; parsed[argDef.name] = value;
} else { // Command } else { // Command

View File

@ -1,4 +1,3 @@
import {ASet} from './aset.ts';
import {dotNotation, isEqual} from './objects'; import {dotNotation, isEqual} from './objects';
/** /**
@ -29,7 +28,10 @@ export function addUnique<T>(array: T[], el: T): T[] {
* @deprecated Use ASet to perform Set operations on arrays * @deprecated Use ASet to perform Set operations on arrays
*/ */
export function arrayDiff(a: any[], b: any[]): any[] { export function arrayDiff(a: any[], b: any[]): any[] {
return new ASet(a).symmetricDifference(new ASet(b)); return makeUnique([
...a.filter(v1 => !b.includes((v2: any) => isEqual(v1, v2))),
...b.filter(v1 => !a.includes((v2: any) => isEqual(v1, v2))),
]);
} }
/** /**

View File

@ -29,14 +29,6 @@ export class ASet<T> extends Array {
return this; return this;
} }
/**
* Remove all elements
*/
clear() {
this.splice(0, this.length);
return this;
}
/** /**
* Delete elements from set * Delete elements from set
* @param items Elements that will be deleted * @param items Elements that will be deleted

View File

@ -1,27 +1,22 @@
import {Table} from './database.ts'; import {deepCopy} from './objects.ts';
import {deepCopy, includes, JSONSanitize} from './objects.ts';
export type CacheOptions = { export type CacheOptions = {
/** Delete keys automatically after x amount of seconds */ /** Delete keys automatically after x amount of seconds */
ttl?: number; ttl?: number;
/** Storage to persist cache */ /** Storage to persist cache */
storage?: Storage | Table<any, any>; storage?: Storage;
/** Key cache will be stored under */ /** Key cache will be stored under */
storageKey?: string; storageKey?: string;
/** Keep or delete cached items once expired, defaults to delete */
expiryPolicy?: 'delete' | 'keep';
} }
export type CachedValue<T> = T & {_expired?: boolean};
/** /**
* Map of data which tracks whether it is a complete collection & offers optional expiry of cached values * Map of data which tracks whether it is a complete collection & offers optional expiry of cached values
*/ */
export class Cache<K extends string | number | symbol, T> { export class Cache<K extends string | number | symbol, T> {
private store: Record<K, T> = <any>{}; private store = <Record<K, T>>{};
/** Support index lookups */ /** Support index lookups */
[key: string | number | symbol]: CachedValue<T> | any; [key: string | number | symbol]: T | any;
/** Whether cache is complete */ /** Whether cache is complete */
complete = false; complete = false;
@ -31,24 +26,19 @@ export class Cache<K extends string | number | symbol, T> {
* @param options * @param options
*/ */
constructor(public readonly key?: keyof T, public readonly options: CacheOptions = {}) { constructor(public readonly key?: keyof T, public readonly options: CacheOptions = {}) {
if(options.storageKey && !options.storage && typeof(Storage) !== 'undefined') options.storage = localStorage; if(options.storageKey && !options.storage && typeof(Storage) !== 'undefined')
if(options.storage) { options.storage = localStorage;
if(options.storage instanceof Table) { if(options.storageKey && options.storage) {
(async () => (await options.storage?.getAll()).forEach((v: any) => { const stored = options.storage.getItem(options.storageKey);
if(v) { if(stored) {
try { this.add(v) } try { Object.assign(this.store, JSON.parse(stored)); }
catch { } catch { }
}
}))()
} else if(options.storageKey) {
const stored = options.storage?.getItem(options.storageKey);
if(stored != null) try { Object.assign(this.store, JSON.parse(stored)); } catch { }
} }
} }
return new Proxy(this, { return new Proxy(this, {
get: (target: this, prop: string | symbol) => { get: (target: this, prop: string | symbol) => {
if(prop in target) return (target as any)[prop]; if(prop in target) return (target as any)[prop];
return this.get(prop as K, true); return deepCopy(target.store[prop as K]);
}, },
set: (target: this, prop: string | symbol, value: any) => { set: (target: this, prop: string | symbol, value: any) => {
if(prop in target) (target as any)[prop] = value; if(prop in target) (target as any)[prop] = value;
@ -60,27 +50,15 @@ export class Cache<K extends string | number | symbol, T> {
private getKey(value: T): K { private getKey(value: T): K {
if(!this.key) throw new Error('No key defined'); if(!this.key) throw new Error('No key defined');
if(value[this.key] === undefined) throw new Error(`${this.key.toString()} Doesn't exist on ${JSON.stringify(value, null, 2)}`);
return <K>value[this.key]; return <K>value[this.key];
} }
private save(key: K) {
if(this.options.storage) {
if(this.options.storage instanceof Table) {
this.options.storage.put(key, this.store[key]);
} else if(this.options.storageKey) {
this.options.storage.setItem(this.options.storageKey, JSONSanitize(this.store));
}
}
}
/** /**
* Get all cached items * Get all cached items
* @return {T[]} Array of items * @return {T[]} Array of items
*/ */
all(expired?: boolean): CachedValue<T>[] { all(): T[] {
return deepCopy<any>(Object.values(this.store) return deepCopy(Object.values(this.store));
.filter((v: any) => expired || !v._expired));
} }
/** /**
@ -111,85 +89,51 @@ export class Cache<K extends string | number | symbol, T> {
/** /**
* Remove all keys from cache * Remove all keys from cache
*/ */
clear(): this { clear() {
this.complete = false; this.store = <Record<K, T>>{};
this.store = <any>{};
return this;
} }
/** /**
* Delete an item from the cache * Delete an item from the cache
* @param {K} key Item's primary key * @param {K} key Item's primary key
*/ */
delete(key: K): this { delete(key: K) {
delete this.store[key]; delete this.store[key];
this.save(key); if(this.options.storageKey && this.options.storage)
return this; this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store));
} }
/** /**
* Return cache as an array of key-value pairs * Return cache as an array of key-value pairs
* @return {[K, T][]} Key-value pairs array * @return {[K, T][]} Key-value pairs array
*/ */
entries(expired?: boolean): [K, CachedValue<T>][] { entries(): [K, T][] {
return deepCopy<any>(Object.entries(this.store) return <[K, T][]>Object.entries(this.store);
.filter((v: any) => expired || !v?._expired));
}
/**
* Manually expire a cached item
* @param {K} key Key to expire
*/
expire(key: K): this {
this.complete = false;
if(this.options.expiryPolicy == 'keep') {
(<any>this.store[key])._expired = true;
this.save(key);
} else this.delete(key);
return this;
}
/**
* Find the first cached item to match a filter
* @param {Partial<T>} filter Partial item to match
* @param {Boolean} expired Include expired items, defaults to false
* @returns {T | undefined} Cached item or undefined if nothing matched
*/
find(filter: Partial<T>, expired?: boolean): T | undefined {
return <T>Object.values(this.store).find((row: any) => (expired || !row._expired) && includes(row, filter));
} }
/** /**
* Get item from the cache * Get item from the cache
* @param {K} key Key to lookup * @param {K} key Key to lookup
* @param expired Include expired items
* @return {T} Cached item * @return {T} Cached item
*/ */
get(key: K, expired?: boolean): CachedValue<T> | null { get(key: K): T {
const cached = deepCopy<any>(this.store[key] ?? null); return deepCopy(this.store[key]);
if(expired || !cached?._expired) return cached;
return null;
} }
/** /**
* Get a list of cached keys * Get a list of cached keys
* @return {K[]} Array of keys * @return {K[]} Array of keys
*/ */
keys(expired?: boolean): K[] { keys(): K[] {
return <K[]>Object.keys(this.store) return <K[]>Object.keys(this.store);
.filter(k => expired || !(<any>this.store)[k]._expired);
} }
/** /**
* Get map of cached items * Get map of cached items
* @return {Record<K, T>} * @return {Record<K, T>}
*/ */
map(expired?: boolean): Record<K, CachedValue<T>> { map(): Record<K, T> {
const copy: any = deepCopy(this.store); return deepCopy(this.store);
if(!expired) Object.keys(copy).forEach(k => {
if(copy[k]._expired) delete copy[k]
});
return copy;
} }
/** /**
@ -200,13 +144,13 @@ export class Cache<K extends string | number | symbol, T> {
* @return {this} * @return {this}
*/ */
set(key: K, value: T, ttl = this.options.ttl): this { set(key: K, value: T, ttl = this.options.ttl): this {
if(this.options.expiryPolicy == 'keep') delete (<any>value)._expired;
this.store[key] = value; this.store[key] = value;
this.save(key); if(this.options.storageKey && this.options.storage)
this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store));
if(ttl) setTimeout(() => { if(ttl) setTimeout(() => {
this.expire(key); this.complete = false;
this.save(key); this.delete(key);
}, (ttl || 0) * 1000); }, ttl * 1000);
return this; return this;
} }

View File

@ -3,10 +3,10 @@
* @param {string} background Color to compare against * @param {string} background Color to compare against
* @return {"white" | "black"} Color with the most contrast * @return {"white" | "black"} Color with the most contrast
*/ */
export function contrast(background: string): 'white' | 'black' { export function blackOrWhite(background: string): 'white' | 'black' {
const exploded = background?.match(background.length >= 6 ? /[0-9a-fA-F]{2}/g : /[0-9a-fA-F]/g); const exploded = background?.match(background.length >= 6 ? /\w\w/g : /\w/g);
if(!exploded || exploded?.length < 3) return 'black'; if(!exploded) return 'black';
const [r, g, b] = exploded.map(hex => parseInt(hex.length == 1 ? `${hex}${hex}` : hex, 16)); const [r, g, b] = exploded.map(hex => parseInt(hex, 16));
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? 'black' : 'white'; return luminance > 0.5 ? 'black' : 'white';
} }

View File

@ -1,157 +0,0 @@
import {findByProp} from './array.ts';
import {ASet} from './aset.ts';
export type TableOptions = {
name: string;
key?: string;
autoIncrement?: boolean;
};
export class Database {
connection!: Promise<IDBDatabase>;
ready = false;
tables!: TableOptions[];
constructor(public readonly database: string, tables?: (string | TableOptions)[], public version?: number) {
this.connection = new Promise((resolve, reject) => {
const req = indexedDB.open(this.database, this.version);
this.tables = !tables ? [] : tables.map(t => {
t = typeof t == 'object' ? t : {name: t};
return {...t, name: t.name.toString()};
});
req.onerror = () => reject(req.error);
req.onsuccess = () => {
const db = req.result;
const existing = Array.from(db.objectStoreNames);
if(!tables) this.tables = existing.map(t => {
const tx = db.transaction(t, 'readonly', )
const store = tx.objectStore(t);
return {name: t, key: <string>store.keyPath};
});
const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name));
if(tables && desired.symmetricDifference(new ASet(existing)).length) {
db.close();
Object.assign(this, new Database(this.database, this.tables, db.version + 1));
this.connection.then(resolve);
} else {
this.version = db.version;
resolve(db);
}
this.ready = true;
};
req.onupgradeneeded = () => {
const db = req.result;
const existingTables = new ASet(Array.from(db.objectStoreNames));
if(tables) {
const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name));
existingTables.difference(desired).forEach(name => db.deleteObjectStore(name));
desired.difference(existingTables).forEach(name => {
const t = this.tables.find(findByProp('name', name));
db.createObjectStore(name, {
keyPath: t?.key,
autoIncrement: t?.autoIncrement || !t?.key
});
});
}
};
});
}
async createTable<K extends IDBValidKey = any, T = any>(table: string | TableOptions): Promise<Table<K, T>> {
if(typeof table == 'string') table = {name: table};
const conn = await this.connection;
if(!this.includes(table.name)) {
conn.close();
Object.assign(this, new Database(this.database, [...this.tables, table], (this.version ?? 0) + 1));
}
return this.table<K, T>(table.name);
}
async deleteTable(table: string | TableOptions): Promise<void> {
if(typeof table == 'string') table = {name: table};
if(!this.includes(table.name)) return;
const conn = await this.connection;
conn.close();
Object.assign(this, new Database(this.database, this.tables.filter(t => t.name != table.name), (this.version ?? 0) + 1));
}
includes(name: any): boolean {
return !!this.tables.find(t => t.name == (typeof name == 'object' ? name.name : name.toString()));
}
table<K extends IDBValidKey = any, T = any>(name: any): Table<K, T> {
return new Table<K, T>(this, name.toString());
}
}
export class Table<K extends IDBValidKey = any, T = any> {
constructor(private readonly database: Database, public readonly name: string, public readonly key: keyof T | string = 'id') {
this.database.connection.then(() => {
const exists = !!this.database.tables.find(findByProp('name', this.name));
if(!exists) this.database.createTable(this.name);
});
}
async tx<R>(table: string, fn: (store: IDBObjectStore) => IDBRequest, readonly = false): Promise<R> {
const db = await this.database.connection;
const tx = db.transaction(table, readonly ? 'readonly' : 'readwrite');
const store = tx.objectStore(table);
return new Promise<R>((resolve, reject) => {
const request = fn(store);
request.onsuccess = () => resolve(request.result as R); // ✅ explicit cast
request.onerror = () => reject(request.error);
});
}
add(value: T, key?: K): Promise<void> {
return this.tx(this.name, store => store.add(value, key));
}
all = this.getAll;
clear(): Promise<void> {
return this.tx(this.name, store => store.clear());
}
count(): Promise<number> {
return this.tx(this.name, store => store.count(), true);
}
create = this.add;
delete(key: K): Promise<void> {
return this.tx(this.name, store => store.delete(key));
}
get(key: K): Promise<T> {
return this.tx(this.name, store => store.get(key), true);
}
getAll(): Promise<T[]> {
return this.tx(this.name, store => store.getAll(), true);
}
getAllKeys(): Promise<K[]> {
return this.tx(this.name, store => store.getAllKeys(), true);
}
put(key: K, value: T): Promise<void> {
return this.tx(this.name, store => store.put(value, key));
}
read(): Promise<T[]>;
read(key: K): Promise<T>;
read(key?: K): Promise<T | T[]> {
return key ? this.get(key) : this.getAll();
}
set(value: T, key?: K): Promise<void> {
if(!key && !(<any>value)[this.key]) return this.add(value);
return this.put(key || (<any>value)[this.key], value);
}
update = this.set;
}

View File

@ -17,7 +17,7 @@ export class TypedEmitter<T extends TypedEvents = TypedEvents> {
static off(event: any, listener: TypedListener) { static off(event: any, listener: TypedListener) {
const e = event.toString(); 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) { static on(event: any, listener: TypedListener) {
@ -43,7 +43,7 @@ export class TypedEmitter<T extends TypedEvents = TypedEvents> {
}; };
off<K extends keyof T = string>(event: K, listener: T[K]) { off<K extends keyof T = string>(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<K extends keyof T = string>(event: K, listener: T[K]) { on<K extends keyof T = string>(event: K, listener: T[K]) {

View File

@ -77,7 +77,7 @@ export function fileText(file: any): Promise<string | null> {
*/ */
export function timestampFilename(name?: string, date: Date | number | string = new Date()) { export function timestampFilename(name?: string, date: Date | number | string = new Date()) {
if(typeof date == 'number' || typeof date == 'string') date = new Date(date); if(typeof date == 'number' || typeof date == 'string') date = new Date(date);
const timestamp = formatDate('YYYY-MM-DD_HH-mm', date); const timestamp = formatDate('YYYY-MM-DD_HH:mm:ss', date);
return name ? name.replace('{{TIMESTAMP}}', timestamp) : timestamp; return name ? name.replace('{{TIMESTAMP}}', timestamp) : timestamp;
} }

View File

@ -4,7 +4,6 @@ export * from './aset';
export * from './cache'; export * from './cache';
export * from './color'; export * from './color';
export * from './csv'; export * from './csv';
export * from './database';
export * from './files'; export * from './files';
export * from './emitter'; export * from './emitter';
export * from './errors'; export * from './errors';
@ -20,4 +19,3 @@ export * from './search';
export * from './string'; export * from './string';
export * from './time'; export * from './time';
export * from './types'; export * from './types';
export * from 'var-persist';

View File

@ -1,23 +1,7 @@
import {JSONAttemptParse} from './objects.ts'; import {JSONAttemptParse} from './objects.ts';
/** /**
* Creates a JSON Web Token (JWT) using the provided payload. * Decode a JWT payload, this will not check for tampering so be careful
*
* @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 * @param {string} token JWT to decode
* @return {unknown} JWT payload * @return {unknown} JWT payload

View File

@ -7,29 +7,24 @@
* ``` * ```
* *
* @param {number} num Number to convert * @param {number} num Number to convert
* @param maxDen
* @return {string} Fraction with remainder * @return {string} Fraction with remainder
*/ */
export function dec2Frac(num: number, maxDen=1000): string { export function dec2Frac(num: number) {
let sign = Math.sign(num); const gcd = (a: number, b: number): number => {
num = Math.abs(num); if (b < 0.0000001) return a;
if (Number.isInteger(num)) return (sign * num) + ""; return gcd(b, ~~(a % b));
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 : '');
}
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)}`;
}
/** /**
* Convert fraction to decimal number * Convert fraction to decimal number

View File

@ -1,26 +1,15 @@
import {PathEvent} from './path-events.ts'; import {PathEvent} from './path-events.ts';
import {md5} from './string'; import {md5} from './string';
/**
* Escape any regex special characters to avoid misinterpretation during search
*
* @param {string} value String which should be escaped
* @return {string} New escaped sequence
*/
export function escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&');
}
/** /**
* Run a stringified function with arguments asynchronously * Run a stringified function with arguments asynchronously
* @param {object} args Map of key/value arguments * @param {object} args Map of key/value arguments
* @param {string} fn Function as string * @param {string} fn Function as string
* @param {boolean} async Run with async (returns a promise) * @return {Promise<T>} Function string response
* @return {T | Promise<T>} Function return result
*/ */
export function fn<T>(args: object, fn: string, async: boolean = false): T { export function asyncFunction<T>(args: object, fn: string): Promise<T> {
const keys = Object.keys(args); const keys = Object.keys(args);
return new Function(...keys, `return (${async ? 'async ' : ''}(${keys.join(',')}) => { ${fn} })(${keys.join(',')})`)(...keys.map(k => (<any>args)[k])); return new Function(...keys, `return (async (${keys.join(',')}) => { ${fn} })(${keys.join(',')})`)(...keys.map(k => (<any>args)[k]));
} }
/** /**
@ -36,25 +25,14 @@ export function gravatar(email: string, def='mp') {
} }
/** /**
* Convert IPv6 to v4 because who uses that, NAT4Life * Escape any regex special characters to avoid misinterpretation during search
* @param {string} ip IPv6 address, e.g. 2001:0db8:85a3:0000:0000:8a2e:0370:7334 *
* @returns {string | null} IPv4 address, e.g. 172.16.58.3 * @param {string} value String which should be escaped
* @return {string} New escaped sequence
*/ */
export function ipV6ToV4(ip: string) { export function escapeRegex(value: string) {
if(!ip) return null; return value.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&');
const ipv4 = ip.split(':').splice(-1)[0];
if(ipv4 == '1') return '127.0.0.1';
return ipv4;
} }
/**
* 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; 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; export type Unsubscribe = () => void;

View File

@ -14,7 +14,7 @@
export function clean<T>(obj: T, undefinedOnly = false): Partial<T> { export function clean<T>(obj: T, undefinedOnly = false): Partial<T> {
if(obj == null) throw new Error("Cannot clean a NULL value"); if(obj == null) throw new Error("Cannot clean a NULL value");
if(Array.isArray(obj)) { if(Array.isArray(obj)) {
obj = <any>obj.filter(o => undefinedOnly ? o !== undefined : o != null); obj = <any>obj.filter(o => o != null);
} else { } else {
Object.entries(obj).forEach(([key, value]) => { Object.entries(obj).forEach(([key, value]) => {
if((undefinedOnly && value === undefined) || (!undefinedOnly && value == null)) delete (<any>obj)[key]; if((undefinedOnly && value === undefined) || (!undefinedOnly && value == null)) delete (<any>obj)[key];
@ -128,6 +128,7 @@ export function flattenObj(obj: any, parent?: any, result: any = {}) {
for(const key of Object.keys(obj)) { for(const key of Object.keys(obj)) {
const propName = parent ? `${parent}.${key}` : key; const propName = parent ? `${parent}.${key}` : key;
if(typeof obj[key] === 'object' && obj[key] != null && !Array.isArray(obj[key])) { if(typeof obj[key] === 'object' && obj[key] != null && !Array.isArray(obj[key])) {
console.log(propName, );
flattenObj(obj[key], propName, result); flattenObj(obj[key], propName, result);
} else { } else {
result[propName] = obj[key]; result[propName] = obj[key];
@ -241,12 +242,10 @@ export function JSONSerialize<T1>(obj: T1): T1 | string {
* @return {string} JSON string * @return {string} JSON string
*/ */
export function JSONSanitize(obj: any, space?: number): string { export function JSONSanitize(obj: any, space?: number): string {
const cache: any[] = []; let cache: any[] = [];
return JSON.stringify(obj, (key, value) => { return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) { if(typeof value === 'object' && value !== null)
if (cache.includes(value)) return '[Circular]'; if(!cache.includes(value)) cache.push(value);
cache.push(value);
}
return value; return value;
}, space); }, space);
} }

View File

@ -31,7 +31,7 @@ export function PE(str: TemplateStringsArray, ...args: any[]) {
if(str[i]) combined.push(str[i]); if(str[i]) combined.push(str[i]);
if(args[i]) combined.push(args[i]); if(args[i]) combined.push(args[i]);
} }
return new PathEvent(combined.join('/')); return new PathEvent(combined.join(''));
} }
/** /**
@ -48,7 +48,7 @@ export function PES(str: TemplateStringsArray, ...args: any[]) {
if(str[i]) combined.push(str[i]); if(str[i]) combined.push(str[i]);
if(args[i]) combined.push(args[i]); if(args[i]) combined.push(args[i]);
} }
const [paths, methods] = combined.join('/').split(':'); const [paths, methods] = combined.join('').split(':');
return PathEvent.toString(paths, <any>methods?.split('')); return PathEvent.toString(paths, <any>methods?.split(''));
} }
@ -79,35 +79,35 @@ export class PathEvent {
set none(v: boolean) { v ? this.methods = new ASet<Method>(['n']) : this.methods.delete('n'); } set none(v: boolean) { v ? this.methods = new ASet<Method>(['n']) : this.methods.delete('n'); }
/** Create method specified */ /** Create method specified */
get create(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('c')) } get create(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('c')) }
set create(v: boolean) { v ? this.methods.delete('n').delete('*').add('c') : this.methods.delete('c'); } set create(v: boolean) { v ? this.methods.delete('n').add('c') : this.methods.delete('c'); }
/** Read method specified */ /** Read method specified */
get read(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('r')) } get read(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('r')) }
set read(v: boolean) { v ? this.methods.delete('n').delete('*').add('r') : this.methods.delete('r'); } set read(v: boolean) { v ? this.methods.delete('n').add('r') : this.methods.delete('r'); }
/** Update method specified */ /** Update method specified */
get update(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('u')) } get update(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('u')) }
set update(v: boolean) { v ? this.methods.delete('n').delete('*').add('u') : this.methods.delete('u'); } set update(v: boolean) { v ? this.methods.delete('n').add('u') : this.methods.delete('u'); }
/** Delete method specified */ /** Delete method specified */
get delete(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('d')) } get delete(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('d')) }
set delete(v: boolean) { v ? this.methods.delete('n').delete('*').add('d') : this.methods.delete('d'); } set delete(v: boolean) { v ? this.methods.delete('n').add('d') : this.methods.delete('d'); }
constructor(e: string | PathEvent) { constructor(Event: string | PathEvent) {
if(typeof e == 'object') return Object.assign(this, e); if(typeof Event == 'object') return Object.assign(this, Event);
let [p, scope, method] = e.replaceAll(/\/{2,}/g, '/').split(':'); let [p, scope, method] = Event.replaceAll(/\/{2,}/g, '/').split(':');
if(!method) method = scope || '*'; if(!method) method = scope || '*';
if(p == '*' || !p && method == '*') { if(p == '*' || !p && method == '*') {
p = ''; p = '';
method = '*'; method = '*';
} }
let temp = p.split('/').filter(p => !!p); let temp = p.split('/').filter(p => !!p);
this.module = temp.splice(0, 1)[0] || ''; this.module = temp.splice(0, 1)[0]?.toLowerCase() || '';
this.fullPath = p;
this.path = temp.join('/'); this.path = temp.join('/');
this.fullPath = `${this.module}${this.module && this.path ? '/' : ''}${this.path}`;
this.name = temp.pop() || ''; this.name = temp.pop() || '';
this.methods = new ASet(<any>method.split('')); this.methods = new ASet(<any>method.split(''));
} }
/** /**
* Combine multiple events into one parsed object. Longest path takes precedent, but all subsequent methods are * Combine multiple events into one parsed object. Longest path takes precedent, but all subsequent methods are
* combined until a "none" is reached * combined until a "none" is reached
* *
* @param {string | PathEvent} paths Events as strings or pre-parsed * @param {string | PathEvent} paths Events as strings or pre-parsed
@ -120,7 +120,6 @@ export class PathEvent {
const l1 = p1.fullPath.length, l2 = p2.fullPath.length; const l1 = p1.fullPath.length, l2 = p2.fullPath.length;
return l1 < l2 ? 1 : (l1 > l2 ? -1 : 0); return l1 < l2 ? 1 : (l1 > l2 ? -1 : 0);
}).reduce((acc, p) => { }).reduce((acc, p) => {
if(acc && !acc.fullPath.startsWith(p.fullPath)) return acc;
if(p.none) hitNone = true; if(p.none) hitNone = true;
if(!acc) return p; if(!acc) return p;
if(hitNone) return acc; if(hitNone) return acc;
@ -140,10 +139,13 @@ export class PathEvent {
*/ */
static filter(target: string | PathEvent | (string | PathEvent)[], ...filter: (string | PathEvent)[]): PathEvent[] { static filter(target: string | PathEvent | (string | PathEvent)[], ...filter: (string | PathEvent)[]): PathEvent[] {
const parsedTarget = makeArray(target).map(pe => new PathEvent(pe)); const parsedTarget = makeArray(target).map(pe => new PathEvent(pe));
const parsedFilter = makeArray(filter).map(pe => new PathEvent(pe)); const parsedFind = makeArray(filter).map(pe => new PathEvent(pe));
return parsedTarget.filter(t => !!parsedFilter.find(f => return parsedTarget.filter(t => {
(t.fullPath == '*' || f.fullPath == '*' || t.fullPath.startsWith(f.fullPath) || f.fullPath.startsWith(t.fullPath)) && if(!t.fullPath && t.all) return true;
(f.all || t.all || t.methods.intersection(f.methods).length))); return !!parsedFind.find(f =>
(t.fullPath.startsWith(f.fullPath) || f.fullPath.startsWith(t.fullPath)) &&
(f.all || t.all || t.methods.intersection(f.methods).length));
});
} }
/** /**
@ -154,12 +156,15 @@ export class PathEvent {
* @return {boolean} Whether there is any overlap * @return {boolean} Whether there is any overlap
*/ */
static has(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): boolean { static has(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): boolean {
const parsedTarget = makeArray(target).map(pe => new PathEvent(pe));
const parsedRequired = makeArray(has).map(pe => new PathEvent(pe)); const parsedRequired = makeArray(has).map(pe => new PathEvent(pe));
return !!parsedRequired.find(r => !!parsedTarget.find(t => const parsedTarget = makeArray(target).map(pe => new PathEvent(pe));
(r.fullPath == '*' || t.fullPath == '*' || r.fullPath.startsWith(t.fullPath)) && return !!parsedRequired.find(r => {
(r.all || t.all || r.methods.intersection(t.methods).length) if(!r.fullPath && r.all) return true;
)); const filtered = parsedTarget.filter(p => r.fullPath.startsWith(p.fullPath));
if(!filtered.length) return false;
const combined = PathEvent.combine(...filtered);
return (!combined.none && (combined.all || r.all)) || combined.methods.intersection(r.methods).length;
});
} }
/** /**
@ -194,14 +199,14 @@ export class PathEvent {
} }
/** /**
* Create event string from its components * Create event string from its components
* *
* @param {string | string[]} path Event path * @param {string | string[]} path Event path
* @param {Method} methods Event method * @param {Method} methods Event method
* @return {string} String representation of Event * @return {string} String representation of Event
*/ */
static toString(path: string | string[], methods: Method | Method[]): string { static toString(path: string | string[], methods: Method | Method[]): string {
let p = makeArray(path).filter(p => !!p).join('/'); let p = makeArray(path).filter(p => p != null).join('/');
p = p?.trim().replaceAll(/\/{2,}/g, '/').replaceAll(/(^\/|\/$)/g, ''); p = p?.trim().replaceAll(/\/{2,}/g, '/').replaceAll(/(^\/|\/$)/g, '');
if(methods?.length) p += `:${makeArray(methods).map(m => m.toLowerCase()).join('')}`; if(methods?.length) p += `:${makeArray(methods).map(m => m.toLowerCase()).join('')}`;
return p; return p;
@ -218,7 +223,7 @@ export class PathEvent {
} }
/** /**
* Create event string from its components * Create event string from its components
* *
* @return {string} String representation of Event * @return {string} String representation of Event
*/ */
@ -230,12 +235,11 @@ export class PathEvent {
export type PathListener = (event: PathEvent, ...args: any[]) => any; export type PathListener = (event: PathEvent, ...args: any[]) => any;
export type PathUnsubscribe = () => void; export type PathUnsubscribe = () => void;
export type Event = string | PathEvent;
export interface IPathEventEmitter { export interface IPathEventEmitter {
emit(event: Event, ...args: any[]): void; emit(event: string, ...args: any[]): void;
off(listener: PathListener): void; off(listener: PathListener): void;
on(event: Event | Event[], listener: PathListener): PathUnsubscribe; on(event: string, listener: PathListener): PathUnsubscribe;
once(event: Event | Event[], listener?: PathListener): Promise<any>; once(event: string, listener?: PathListener): Promise<any>;
relayEvents(emitter: PathEventEmitter): void; relayEvents(emitter: PathEventEmitter): void;
} }
@ -245,11 +249,9 @@ export interface IPathEventEmitter {
export class PathEventEmitter implements IPathEventEmitter{ export class PathEventEmitter implements IPathEventEmitter{
private listeners: [PathEvent, PathListener][] = []; private listeners: [PathEvent, PathListener][] = [];
constructor(public readonly prefix: string = '') { } emit(event: string | PathEvent, ...args: any[]) {
const parsed = new PathEvent(event);
emit(event: Event, ...args: any[]) { this.listeners.filter(l => PathEvent.has(l[0], event))
const parsed = PE`${this.prefix}/${event}`;
this.listeners.filter(l => PathEvent.has(l[0], parsed))
.forEach(async l => l[1](parsed, ...args)); .forEach(async l => l[1](parsed, ...args));
}; };
@ -257,15 +259,12 @@ export class PathEventEmitter implements IPathEventEmitter{
this.listeners = this.listeners.filter(l => l[1] != listener); this.listeners = this.listeners.filter(l => l[1] != listener);
} }
on(event: Event | Event[], listener: PathListener): PathUnsubscribe { on(event: string | string[], listener: PathListener): PathUnsubscribe {
makeArray(event).forEach(e => this.listeners.push([ makeArray(event).forEach(e => this.listeners.push([new PathEvent(e), listener]));
new PathEvent(`${this.prefix}/${e}`),
listener
]));
return () => this.off(listener); return () => this.off(listener);
} }
once(event: Event | Event[], listener?: PathListener): Promise<any> { once(event: string | string[], listener?: PathListener): Promise<any> {
return new Promise(res => { return new Promise(res => {
const unsubscribe = this.on(event, (event: PathEvent, ...args: any[]) => { const unsubscribe = this.on(event, (event: PathEvent, ...args: any[]) => {
res(args.length < 2 ? args[0] : args); res(args.length < 2 ? args[0] : args);

View File

@ -1,14 +1,5 @@
import {dotNotation, JSONAttemptParse, JSONSerialize} from './objects.ts'; import {dotNotation, JSONAttemptParse} 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) { export function search(rows: any[], search: string, regex?: boolean, transform: Function = (r: any) => r) {
if(!rows) return []; if(!rows) return [];
return rows.filter(r => { return rows.filter(r => {
@ -22,18 +13,12 @@ export function search(rows: any[], search: string, regex?: boolean, transform:
catch { return false; } catch { return false; }
}).length }).length
} else { } else {
return logicTest(r, search); return testCondition(search, r);
} }
}); });
} }
/** export function testCondition(condition: string, row: any) {
* Test an object against a logic condition. By default values are checked
* @param {string} condition
* @param {object} target
* @return {boolean}
*/
export function logicTest(target: object, condition: string): boolean {
const evalBoolean = (a: any, op: string, b: any): boolean => { const evalBoolean = (a: any, op: string, b: any): boolean => {
switch(op) { switch(op) {
case '=': case '=':
@ -55,11 +40,11 @@ export function logicTest(target: object, condition: string): boolean {
// Boolean operator // Boolean operator
const prop = /(\S+)\s*(==?|!=|>=|>|<=|<)\s*(\S+)/g.exec(p); const prop = /(\S+)\s*(==?|!=|>=|>|<=|<)\s*(\S+)/g.exec(p);
if(prop) { if(prop) {
const key = Object.keys(target).find(k => k.toLowerCase() == prop[1].toLowerCase()); const key = Object.keys(row).find(k => k.toLowerCase() == prop[1].toLowerCase());
return evalBoolean(dotNotation<any>(target, key || prop[1]), prop[2], JSONAttemptParse(prop[3])); return evalBoolean(dotNotation<any>(row, key || prop[1]), prop[2], JSONAttemptParse(prop[3]));
} }
// Case-sensitive // Case-sensitive
const v = Object.values(target).map(JSONSerialize).join(''); const v = Object.values(row).map(v => typeof v == 'object' && v != null ? JSON.stringify(v) : v).join('');
if(/[A-Z]/g.test(condition)) return v.includes(p); if(/[A-Z]/g.test(condition)) return v.includes(p);
// Case-insensitive // Case-insensitive
return v.toLowerCase().includes(p); return v.toLowerCase().includes(p);

View File

@ -21,13 +21,11 @@ export const CHAR_LIST = LETTER_LIST + LETTER_LIST.toLowerCase() + NUMBER_LIST +
/** /**
* Converts text to camelCase * Converts text to camelCase
*/ */
export function camelCase(str?: string): string { export function camelCase(str?: string) {
if(!str) return ''; const text = pascalCase(str);
const pascal = pascalCase(str); return text[0].toLowerCase() + text.slice(1);
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
} }
/** /**
* Convert number of bytes into a human-readable size * Convert number of bytes into a human-readable size
* *
@ -77,12 +75,14 @@ export function insertAt(target: string, str: string, index: number): String {
/** /**
* Converts text to kebab-case * Converts text to kebab-case
*/ */
export function kebabCase(str?: string): string { export function kebabCase(str: string) {
if(!str) return ''; if(!str) return '';
return wordSegments(str).map(w => w.toLowerCase()).join("-"); return str.replaceAll(/(^[^a-zA-Z]+|[^a-zA-Z0-9-_])/g, '')
.replaceAll(/([A-Z]|[0-9]+)/g, (...args) => `-${args[0].toLowerCase()}`)
.replaceAll(/([0-9])([a-z])/g, (...args) => `${args[1]}-${args[2]}`)
.replaceAll(/[^a-z0-9]+(\w?)/g, (...args) => `-${args[1] ?? ''}`).toLowerCase();
} }
/** /**
* Add padding to string * Add padding to string
* *
@ -110,15 +110,13 @@ export function pad(text: any, length: number, char: string = ' ', start = true)
* @param {string} str * @param {string} str
* @return {string} * @return {string}
*/ */
export function pascalCase(str?: string): string { export function pascalCase(str?: string) {
if(!str) return ''; if(!str) return '';
return wordSegments(str) const text = str.replaceAll(/(^[^a-zA-Z]+|[^a-zA-Z0-9-_])/g, '')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) .replaceAll(/[^a-zA-Z0-9]+(\w?)/g, (...args) => args[1]?.toUpperCase() || '');
.join(''); return text[0].toUpperCase() + text.slice(1);
} }
/** /**
* Generate a random hexadecimal value * Generate a random hexadecimal value
* *
@ -185,12 +183,14 @@ export function randomStringBuilder(length: number, letters = false, numbers = f
/** /**
* Converts text to snake_case * Converts text to snake_case
*/ */
export function snakeCase(str?: string): string { export function snakeCase(str?: string) {
if(!str) return ''; if(!str) return '';
return wordSegments(str).map(w => w.toLowerCase()).join("_"); return str.replaceAll(/(^[^a-zA-Z]+|[^a-zA-Z0-9-_])/g, '')
.replaceAll(/([A-Z]|[0-9]+)/g, (...args) => `_${args[0].toLowerCase()}`)
.replaceAll(/([0-9])([a-z])/g, (...args) => `${args[1]}_${args[2]}`)
.replaceAll(/[^a-z0-9]+(\w?)/g, (...args) => `_${args[1] ?? ''}`).toLowerCase();
} }
/** /**
* Splice a string together (Similar to Array.splice) * Splice a string together (Similar to Array.splice)
* *
@ -297,23 +297,6 @@ export function md5(d: string) {
function bit_rol(d:any,_:any){return d<<_|d>>>32-_ function bit_rol(d:any,_:any){return d<<_|d>>>32-_
} }
/**
* Splits a string into logical word segments
*/
export function wordSegments(str?: string): string[] {
if (!str) return [];
return str
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.replace(/([0-9]+)([a-zA-Z])/g, "$1 $2")
.replace(/([a-zA-Z])([0-9]+)/g, "$1 $2")
.replace(/[_\-\s]+/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean);
}
/** /**
* Check if email is valid * Check if email is valid
* *

View File

@ -190,35 +190,3 @@ export async function sleepWhile(fn : () => boolean | Promise<boolean>, checkInt
export function timeUntil(date: Date | number): number { export function timeUntil(date: Date | number): number {
return (date instanceof Date ? date.getTime() : date) - (new Date()).getTime(); return (date instanceof Date ? date.getTime() : date) - (new Date()).getTime();
} }
/**
* Convert a timezone string (e.g., "America/Toronto") to its current UTC offset in minutes.
* @param {string} tz - Timezone string, e.g. "America/Toronto"
* @param {Date} [date=new Date()] - The date for which you want the offset (default is now)
* @returns {number} - Offset in minutes (e.g., -240)
*/
export function timezoneOffset(tz: string, date: Date = new Date()): number {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const parts = dtf.formatToParts(date);
const get = (type: string) => Number(parts.find(v => v.type === type)?.value);
const y = get('year');
const mo = get('month');
const d = get('day');
const h = get('hour');
const m = get('minute');
const s = get('second');
const asUTC = Date.UTC(y, mo - 1, d, h, m, s);
const asLocal = date.getTime();
return Math.round((asLocal - asUTC) / 60000);
}

View File

@ -1,4 +1,20 @@
/** Mark all properties as writable */ /**
export type Writable<T> = { * Return keys on a type as an array of strings
-readonly [P in keyof T]: T[P] *
}; * @example
* ```ts
* type Person = {
* firstName: string;
* lastName: string;
* age: number;
* }
*
* const keys = typeKeys<Person>();
* console.log(keys); // Output: ["firstName", "lastName", "age"]
* ```
*
* @return {Array<keyof T>} Available keys
*/
export function typeKeys<T extends object>() {
return Object.keys(<T>{}) as Array<keyof T>;
}

View File

@ -1,121 +0,0 @@
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');
});
});
});

View File

@ -1,85 +1,82 @@
import {addUnique, arrayDiff, caseInsensitiveSort, findByProp, flattenArr, makeArray, makeUnique, sortByProp,} from '../src'; import {addUnique, caseInsensitiveSort, flattenArr, sortByProp} from '../src';
describe('Array Utilities', () => { describe('Array Utilities', () => {
describe('addUnique', () => { describe('addUnique', () => {
it('does not add duplicate value', () => { const arr = [1, 2];
const arr = [1, 2, 3];
addUnique(arr, 2); test('non-unique', () => {
expect(arr).toEqual([1, 2, 3]); addUnique(arr, 1);
expect(arr).toStrictEqual([1, 2]);
}); });
it('adds unique value', () => { test('unique', () => {
const arr = [1, 2];
addUnique(arr, 3); addUnique(arr, 3);
expect(arr).toEqual([1, 2, 3]); expect(arr).toStrictEqual([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', () => { describe('flattenArr', () => {
it('flattens deeply nested arrays', () => { test('flat array', () => expect(flattenArr([1, 2])).toStrictEqual([1, 2]));
const arr = [1, [2, [3, [4]], 5], 6]; test('2D array', () => expect(flattenArr([[1, 2], [3, 4]])).toStrictEqual([1, 2, 3, 4]));
expect(flattenArr(arr)).toEqual([1, 2, 3, 4, 5, 6]); 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 flat array as-is', () => {
expect(flattenArr([1, 2, 3])).toEqual([1, 2, 3]);
});
}); });
describe('sortByProp', () => { describe('sortByProp', () => {
it('sorts by numeric property', () => { test('random letters', () => {
const arr = [{a: 3}, {a: 1}, {a: 2}]; let unsorted: any = Array(100).fill(null)
arr.sort(sortByProp('a')); .map(() => String.fromCharCode(Math.round(Math.random() * 25) + 97));
expect(arr.map(i => i.a)).toEqual([1, 2, 3]); 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 string property reversed', () => { test('random letters reversed', () => {
const arr = [{a: 'apple'}, {a: 'banana'}, {a: 'pear'}]; let unsorted: any = Array(100).fill(null)
arr.sort(sortByProp('a', true)); .map(() => String.fromCharCode(Math.round(Math.random() * 25) + 97));
expect(arr.map(i => i.a)).toEqual(['pear', 'banana', 'apple']); 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);
}); });
}); });
describe('makeUnique', () => { describe('caseInsensitiveSort', () => {
it('removes duplicate primitives', () => { test('non-string property', () => {
const arr = [1, 2, 2, 3, 1]; const unsorted: any = [{a: 'Apple', b: 123}, {a: 'Carrot', b: 789}, {a: 'banana', b: 456}];
expect(makeUnique(arr)).toEqual([1, 2, 3]); const sorted: any = unsorted.map((u: any) => ({...u}));
expect(unsorted.sort(caseInsensitiveSort('b'))).toStrictEqual(sorted);
}); });
it('removes duplicate objects', () => { test('simple strings', () => {
const obj = {a: 1}; const unsorted: any = [{a: 'Apple'}, {a: 'Carrot'}, {a: 'banana'}];
const arr = [obj, obj, {a: 1}]; const sorted: any = unsorted.sort((first: any, second: any) => {
expect(makeUnique(arr)).toHaveLength(1); return first.a.toLowerCase().localeCompare(second.a.toLowerCase());
}).map((u: any) => ({...u}));
expect(unsorted.sort(caseInsensitiveSort('a'))).toStrictEqual(sorted);
}); });
}); test('alphanumeric strings', () => {
const unsorted: any = [{a: '4pple'}, {a: 'Carrot'}, {a: 'b4n4n4'}];
describe('makeArray', () => { const sorted: any = unsorted.sort((first: any, second: any) => {
it('wraps non-arrays in array', () => { return first.a.toLowerCase().localeCompare(second.a.toLowerCase());
expect(makeArray(1)).toEqual([1]); }).map((u: any) => ({...u}));
}); expect(unsorted.sort(caseInsensitiveSort('a'))).toStrictEqual(sorted);
it('returns array as-is', () => {
expect(makeArray([1, 2])).toEqual([1, 2]);
}); });
}); });
}); });

View File

@ -1,148 +0,0 @@
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(<any>{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);
});
});
});

View File

@ -1,109 +0,0 @@
import {Cache} from '../src';
describe('Cache', () => {
type TestItem = { id: string; value: string; };
let cache: Cache<string, TestItem>;
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<string, TestItem>('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<string, TestItem>('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<string, TestItem>('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);
});
});

View File

@ -1,40 +0,0 @@
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');
});
});

View File

@ -1,90 +0,0 @@
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""]"');
});
});
});

View File

@ -1,118 +0,0 @@
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<Events>;
beforeEach(() => {
emitter = new TypedEmitter<Events>();
});
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();
});
});
});

View File

@ -1,112 +0,0 @@
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);
});
});
});

View File

@ -1,61 +0,0 @@
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<typeof payload>(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<typeof payload>(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<typeof payload>(jwt + '.');
expect(decoded).toEqual(payload);
});
});
});

View File

@ -1,51 +0,0 @@
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);
});
});
});

View File

@ -1,44 +1,36 @@
import {fn, gravatar, escapeRegex, md5} from '../src'; import {sleep, parseUrl} from '../src';
describe('Misc Utilities', () => { describe('Miscellanies Utilities', () => {
describe('fn', () => { describe('sleep', () => {
it('should execute a stringified function with arguments', () => { test('wait until', async () => {
const result = fn({ x: 2, y: 3 }, 'return x + y;'); const wait = ~~(Math.random() * 500);
expect(result).toBe(5); const time = new Date().getTime();
}); await sleep(wait);
expect(new Date().getTime()).toBeGreaterThanOrEqual(time + wait);
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', () => { describe('urlParser', () => {
it('should return empty string if email is falsy', () => { test('localhost w/ port', () => {
expect(gravatar('')).toBe(''); 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');
}); });
it('should build correct gravatar url', () => {
const email = 'test@example.com';
expect(gravatar(email)).toContain(`https://www.gravatar.com/avatar/${md5(email)}`);
});
});
describe('escapeRegex', () => { test('advanced URL', () => {
it('should escape all special regex characters', () => { const parsed = parseUrl('https://sub.domain.example.com/some/path?q1=test1&q2=test2#frag');
const special = '.*+?^${}()|[]\\'; expect(parsed.protocol).toStrictEqual('https');
const escaped = escapeRegex(special); expect(parsed.host).toStrictEqual('sub.domain.example.com');
expect(escaped).toBe('\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\'); expect(parsed.domain).toStrictEqual('example.com');
}); expect(parsed.subdomain).toStrictEqual('sub.domain');
it('should return original string if nothing to escape', () => { expect(parsed.path).toStrictEqual('/some/path');
const normal = 'abc123'; expect(parsed.query).toStrictEqual({q1: 'test1', q2: 'test2'});
const escaped = escapeRegex(normal); expect(parsed.fragment).toStrictEqual('frag');
expect(escaped).toBe('abc123');
}); });
}); });
}); });

View File

@ -1,164 +1,89 @@
import { import {clean, deepCopy, dotNotation, flattenObj, includes, isEqual} from "../src";
clean, deepCopy, deepMerge, dotNotation, encodeQuery, flattenObj, formData, includes, isEqual, mixin,
JSONAttemptParse, JSONSerialize, JSONSanitize describe('Object Utilities', () => {
} from '../src'; const TEST_OBJECT = {
a: 1,
b: [
[2, 3],
[4, 5]
],
c: {
d: [
[{e: 6, f: 7}]
],
},
g: {h: 8},
i: () => 9
};
describe('Object utilities', () => {
describe('clean', () => { describe('clean', () => {
it('removes null values', () => { test('remove null properties', () => {
const obj = { a: 1, b: null, c: undefined, d: 0 }; const a = {a: 1, b: null, c: undefined};
expect(clean({ ...obj })).toEqual({ a: 1, c: undefined, d: 0 }); const final = {a: 1};
expect(clean(a)).toEqual(final);
}); });
it('throws on null input', () => { test('remove undefined properties', () => {
expect(() => clean(null as any)).toThrow(); const a = {a: 1, b: null, c: undefined};
}); const final = {a: 1, b: null};
it('removes undefined only when specified', () => { expect(clean(a, true)).toEqual(final);
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', () => { describe('dotNotation', () => {
it('gets nested value', () => { test('no object or properties', () => {
const obj = { a: { b: { c: 3 } } }; expect(dotNotation(undefined, 'z')).toStrictEqual(undefined);
expect(dotNotation(obj, 'a.b.c')).toBe(3); expect(dotNotation(TEST_OBJECT, '')).toStrictEqual(undefined);
}); });
it('sets nested value', () => { test('invalid property', () => expect(dotNotation(TEST_OBJECT, 'z')).toBeUndefined());
const obj = { a: { b: { c: 3 } } }; test('by property', () => expect(dotNotation(TEST_OBJECT, 'a')).toBe(TEST_OBJECT.a));
dotNotation(obj, 'a.b.c', 10); test('by key', () => expect(dotNotation(TEST_OBJECT, '["a"]')).toBe(TEST_OBJECT['a']));
expect(obj.a.b.c).toBe(10); 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('returns undefined for non-existent path', () => { test('set new value', () => {
expect(dotNotation({ a: 1 }, 'a.b.c')).toBeUndefined(); const COPY = JSON.parse(JSON.stringify(TEST_OBJECT));
}); dotNotation(COPY, 'c.x.y.z', 'test');
it('creates nested object when setting', () => { expect(COPY['c']['x']['y']['z']).toBe('test');
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', () => { describe('includes', () => {
it('checks if all values included', () => { test('simple', () => expect(includes(TEST_OBJECT, {a: 1})).toBeTruthy());
expect(includes({ a: 2, b: 3 }, { a: 2 })).toBeTruthy(); test('nested', () => expect(includes(TEST_OBJECT, {g: {h: 8}})).toBeTruthy());
expect(includes({ a: 2, b: 3 }, { c: 1 })).toBeFalsy(); test('array', () => expect(includes(TEST_OBJECT, {b: [[2]]})).toBeTruthy());
}); test('nested array', () => expect(includes(TEST_OBJECT, {a: 1, c: {d: [[{e: 6}]]}})).toBeTruthy());
it('handles arrays of values', () => { test('wong nested array', () => expect(includes(TEST_OBJECT, {a: 1, c: {d: [{e: 7}]}})).toBeFalsy());
expect(includes([{ a: 1 }], [{ a: 1 }])).toBeTruthy(); test('wrong value', () => expect(includes(TEST_OBJECT, {a: 1, b: 2})).toBeFalsy());
expect(includes([{ a: 1 }], [{ a: 2 }])).toBeFalsy(); test('missing value', () => expect(includes(TEST_OBJECT, {a: 1, i: 10})).toBeFalsy());
});
it('allows missing when specified', () => {
expect(includes(undefined, { a: 2 }, true)).toBeTruthy();
});
}); });
describe('isEqual', () => { describe('isEqual', () => {
it('returns true for deeply equal objects', () => { test('boolean equal', () => expect(isEqual(true, true)).toBeTruthy());
expect(isEqual({ a: 1, b: { c: 3 } }, { a: 1, b: { c: 3 } })).toBe(true); test('boolean not-equal', () => expect(isEqual(true, false)).toBeFalsy());
}); test('number equal', () => expect(isEqual(1, 1)).toBeTruthy());
it('returns false for non-equal objects', () => { test('number not-equal', () => expect(isEqual(1, 0)).toBeFalsy());
expect(isEqual({ a: 1 }, { a: 2 })).toBe(false); test('string equal', () => expect(isEqual('abc', 'abc')).toBeTruthy());
}); test('string not-equal', () => expect(isEqual('abc', '')).toBeFalsy());
it('compares functions by string', () => { test('array equal', () => expect(isEqual([true, 1, 'a'], [true, 1, 'a'])).toBeTruthy());
expect(isEqual(() => 1, () => 1)).toBe(true); 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());
}); });
describe('mixin', () => { describe('flattenObj', () => {
it('merges prototypes', () => { test('simple nested object', () => expect(flattenObj({a: {b: {c: 1}}})).toEqual({"a.b.c": 1}));
class A { foo() { return 1; } } test('already flat object', () => expect(flattenObj(TEST_OBJECT['g'])).toEqual(TEST_OBJECT['g']));
class B { bar() { return 2; } } test('non-object input', () => expect(flattenObj(TEST_OBJECT['b'])).toBeUndefined());
class C {} test('complex nested object', () => expect(flattenObj({a: 1, b: {c: 2}, d: {e: {f: {g: 3}}}}))
mixin(C, [A, B]); .toEqual({"a": 1, "b.c": 2, "d.e.f.g": 3}));
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();
});
}); });
}); });

View File

@ -1,198 +0,0 @@
import {PathError, PathEvent, PathEventEmitter, PE, PES} from '../src';
describe('Path Events', () => {
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('PES', () => {
it('creates string for event', () => {
expect(PES`users/system:cr`).toBe('users/system:cr');
});
});
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);
});
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')).toBeTruthy();
expect(PathEvent.has(events, 'users/nope:r')).toBeFalsy();
});
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');
});
});
});

View File

@ -1,71 +0,0 @@
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);
});
});
});

View File

@ -1,168 +1,50 @@
import { import {matchAll, randomString, randomStringBuilder} from "../src";
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('String Utilities', () => {
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');
});
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('hello world')).toBe('hello-world');
expect(kebabCase('HelloWorld')).toContain('hello-world');
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', () => { describe('randomString', () => {
it('creates a random string from CHAR_LIST of correct length', () => { test('length', () => expect(randomString(32).length).toStrictEqual(32));
const s = randomString(10); test('distribution', () => {
expect(s).toHaveLength(10); const charList = '123';
// letters, numbers, symbols all included in CHAR_LIST 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);
}); });
it('uses provided pool', () => { test('binary', () => {
expect(['0','1']).toContain(randomString(1, '01')); 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);
}); });
}); });
describe('randomStringBuilder', () => { describe('randomStringBuilder', () => {
it('creates with just letters', () => { test('length', () => {
expect(/^[A-Z]+$/.test(randomStringBuilder(5, true, false, false))).toBe(true); const len = ~~(Math.random() * 32);
expect(randomStringBuilder(len, true).length).toStrictEqual(len);
}); });
it('creates with just numbers', () => { test('no length', () => {
expect(/^[0-9]+$/.test(randomStringBuilder(5, false, true, false))).toBe(true); expect(randomStringBuilder(0, true)).toStrictEqual('');
}); });
it('creates with just symbols', () => { test('letters only', () =>
expect(SYMBOL_LIST).toContain(randomStringBuilder(1, false, false, true)); expect(/^[a-zA-Z]{10}$/g.test(randomStringBuilder(10, true))).toBeTruthy());
}); test('numbers only', () =>
it('throws if all false', () => { expect(/^[0-9]{10}$/g.test(<any>randomStringBuilder(10, false, true))).toBeTruthy());
expect(() => randomStringBuilder(5, false, false, false)).toThrow(); 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);
describe('snakeCase', () => { expect(/[a-zA-Z]/g.test(randomString)).toBeTruthy();
it('converts to snake_case', () => { expect(/[0-9]/g.test(randomString)).toBeTruthy();
expect(snakeCase('helloWorld')).toContain('hello_world'); expect(/[^a-zA-Z0-9]/g.test(randomString)).toBeTruthy();
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', () => { describe('matchAll', () => {
it('returns expected matches', () => { test('using string', () => expect(matchAll('fooBar fooBar FooBar', 'fooBar').length).toBe(2));
const matches = matchAll('a1 b2 c3', /\d/g); test('using regex', () => expect(matchAll('fooBar fooBar FooBar', /fooBar/g).length).toBe(2));
expect(matches.length).toBe(3); test('using malformed regex', () => expect(() => matchAll('fooBar fooBar FooBar', /fooBar/)).toThrow());
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('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');
});
});
describe('md5', () => {
it('hashes string to hex', () => {
expect(md5('test')).toMatch(/^[a-f0-9]+$/i);
});
}); });
}); });

View File

@ -1,108 +0,0 @@
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', () => {
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);
});
});
});

View File

@ -1,18 +0,0 @@
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<ReadonlyPerson>;
// 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 });
});
});
});

View File

@ -1,7 +1,7 @@
{ {
"include": ["src"], "include": ["src"],
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"module": "ESNext", "module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"], "lib": ["ESNext", "DOM", "DOM.Iterable"],