Compare commits

..

32 Commits

Author SHA1 Message Date
adafd61c83 Added IPv6 to v4 converter
All checks were successful
Build / Build NPM Project (push) Successful in 1m17s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 52s
2025-06-25 11:48:49 -04:00
7747dc5558 more path event fixes.... i hope...
All checks were successful
Build / Build NPM Project (push) Successful in 43s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 36s
2025-06-24 22:21:34 -04:00
947bdbc6d4 more path event fixes.... i hope...
Some checks failed
Build / Tag Version (push) Has been cancelled
Build / Publish Documentation (push) Has been cancelled
Build / Build NPM Project (push) Has been cancelled
2025-06-24 22:21:30 -04:00
e2b8c35535 more path event fixes.... i hope...
All checks were successful
Build / Build NPM Project (push) Successful in 44s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 37s
2025-06-24 22:05:39 -04:00
3b7d28de4b Fixed test
Some checks failed
Build / Build NPM Project (push) Failing after 39s
Build / Publish Documentation (push) Has been skipped
Build / Tag Version (push) Has been skipped
2025-06-24 20:55:12 -04:00
8f7e816571 path event fixes
Some checks failed
Build / Build NPM Project (push) Failing after 45s
Build / Publish Documentation (push) Has been skipped
Build / Tag Version (push) Has been skipped
2025-06-24 20:52:16 -04:00
8c1fb2d8cb Fixed filename timestamp
All checks were successful
Build / Build NPM Project (push) Successful in 1m20s
Build / Tag Version (push) Successful in 15s
Build / Publish Documentation (push) Successful in 56s
2025-06-23 18:08:25 -04:00
91dc17667e Better cache & database integration
All checks were successful
Build / Build NPM Project (push) Successful in 44s
Build / Tag Version (push) Successful in 13s
Build / Publish Documentation (push) Successful in 51s
2025-06-20 19:41:01 -04:00
11cfc67650 Added cache find function
All checks were successful
Build / Build NPM Project (push) Successful in 41s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 35s
2025-06-19 19:46:59 -04:00
4ed23e1502 Fixed path events casing issue
All checks were successful
Build / Build NPM Project (push) Successful in 46s
Build / Tag Version (push) Successful in 13s
Build / Publish Documentation (push) Successful in 52s
2025-06-19 18:44:20 -04:00
e3bbd13ed8 Removed log statement that snuck in
All checks were successful
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 37s
2025-06-16 23:37:02 -04:00
1877bac7ce Allow dynamically creating tables
All checks were successful
Build / Build NPM Project (push) Successful in 48s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 42s
2025-06-16 20:56:28 -04:00
c06538a725 Fixed word formatters
All checks were successful
Build / Build NPM Project (push) Successful in 45s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 35s
2025-06-11 18:41:46 -04:00
a08b0c4eea Fixed database init loop
All checks were successful
Build / Build NPM Project (push) Successful in 40s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 34s
2025-05-27 15:42:00 -04:00
1b03ae875b Fixed database includes
All checks were successful
Build / Build NPM Project (push) Successful in 41s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 35s
2025-05-27 15:13:53 -04:00
af1da75a82 Removed console.log statments
All checks were successful
Build / Build NPM Project (push) Successful in 41s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 34s
2025-05-27 14:56:24 -04:00
e19768688d Fixed using numbers as database table name
All checks were successful
Build / Build NPM Project (push) Successful in 1m16s
Build / Tag Version (push) Successful in 15s
Build / Publish Documentation (push) Successful in 53s
2025-05-27 14:51:04 -04:00
6d706d4c15 Added database wrapper
All checks were successful
Build / Build NPM Project (push) Successful in 1m7s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 53s
2025-05-25 23:02:56 -04:00
97f2bcce2e removed debugging line
All checks were successful
Build / Build NPM Project (push) Successful in 43s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 36s
2025-05-15 16:47:35 -04:00
2a7fa18c0e Added indexdb wrapper & cache support for it
All checks were successful
Build / Build NPM Project (push) Successful in 1m24s
Build / Tag Version (push) Successful in 53s
Build / Publish Documentation (push) Successful in 1m28s
2025-05-15 16:06:32 -04:00
fec373ca4c Added test suite
All checks were successful
Build / Build NPM Project (push) Successful in 1m16s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 53s
2025-05-14 16:30:42 -04:00
cf122ef9e8 * Fixed cache expiry
All checks were successful
Build / Build NPM Project (push) Successful in 46s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 34s
2025-05-13 11:13:48 -04:00
76b570b3fe * Fixed cache expire checks on uncached
All checks were successful
Build / Build NPM Project (push) Successful in 39s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 35s
2025-05-13 11:10:23 -04:00
4fecf10d11 * Fixed cache value type
All checks were successful
Build / Build NPM Project (push) Successful in 1m16s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 54s
2025-05-13 10:53:17 -04:00
028b9c0f4c + Caching manually expire
All checks were successful
Build / Build NPM Project (push) Successful in 41s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 36s
2025-05-12 20:29:29 -04:00
d938996a66 + Caching expiry strategies
Some checks failed
Build / Build NPM Project (push) Failing after 34s
Build / Publish Documentation (push) Has been skipped
Build / Tag Version (push) Has been skipped
+ Prefix PathEvents
2025-05-12 19:46:23 -04:00
cdcaeda67c + Added writable
All checks were successful
Build / Build NPM Project (push) Successful in 40s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 35s
2025-05-12 18:00:12 -04:00
482c90b53b + Added writable
All checks were successful
Build / Build NPM Project (push) Successful in 1m13s
Build / Tag Version (push) Successful in 15s
Build / Publish Documentation (push) Successful in 49s
2025-05-12 16:28:10 -04:00
7500ba502f + Added clear function to ASet
All checks were successful
Build / Build NPM Project (push) Successful in 44s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 37s
* fixed pathedEvent fullPath casing
2025-05-11 14:13:01 -04:00
edc059d17d Added var-persist
All checks were successful
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 35s
2025-05-11 11:55:40 -04:00
48cfbee46e Removed test
All checks were successful
Build / Build NPM Project (push) Successful in 1m8s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 53s
2025-05-11 11:50:08 -04:00
26cc18ffb3 Fixed path event, renamed testCondition to logicTest & fixed some tests
Some checks failed
Build / Build NPM Project (push) Failing after 44s
Build / Publish Documentation (push) Has been skipped
Build / Tag Version (push) Has been skipped
2025-05-11 11:46:03 -04:00
38 changed files with 2122 additions and 450 deletions

View File

@ -1,104 +1,9 @@
<!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>
<html>
<body>
<header>
<h1>About Us</h1>
<p>Empowering Learning Through Innovation</p>
</header>
<script type="module">
import {PES} from './dist/index.mjs';
<main>
<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>
console.log(PES`storage${'Test/Test'}:d`);
</script>
</body>
</html>

View File

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

View File

@ -72,7 +72,8 @@ export class ArgParser {
extras.push(arg);
continue;
}
const value = argDef.default === false ? true :
const value = combined[1] != null ? combined [1] :
argDef.default === false ? true :
argDef.default === true ? false :
queue.splice(queue.findIndex(q => q[0] != '-'), 1)[0] ||
argDef.default;

View File

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

View File

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

View File

@ -1,22 +1,27 @@
import {deepCopy} from './objects.ts';
import {Table} from './database.ts';
import {deepCopy, includes, JSONSanitize} from './objects.ts';
export type CacheOptions = {
/** Delete keys automatically after x amount of seconds */
ttl?: number;
/** Storage to persist cache */
storage?: Storage;
storage?: Storage | Table<any, any>;
/** Key cache will be stored under */
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
*/
export class Cache<K extends string | number | symbol, T> {
private store = <Record<K, T>>{};
private store: Record<K, T> = <any>{};
/** Support index lookups */
[key: string | number | symbol]: T | any;
[key: string | number | symbol]: CachedValue<T> | any;
/** Whether cache is complete */
complete = false;
@ -26,19 +31,24 @@ export class Cache<K extends string | number | symbol, T> {
* @param options
*/
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) {
const stored = options.storage.getItem(options.storageKey);
if(stored) {
try { Object.assign(this.store, JSON.parse(stored)); }
if(options.storageKey && !options.storage && typeof(Storage) !== 'undefined') options.storage = localStorage;
if(options.storage) {
if(options.storage instanceof Table) {
(async () => (await options.storage?.getAll()).forEach((v: any) => {
if(v) {
try { this.add(v) }
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, {
get: (target: this, prop: string | symbol) => {
if(prop in target) return (target as any)[prop];
return deepCopy(target.store[prop as K]);
return this.get(prop as K, true);
},
set: (target: this, prop: string | symbol, value: any) => {
if(prop in target) (target as any)[prop] = value;
@ -50,15 +60,27 @@ export class Cache<K extends string | number | symbol, T> {
private getKey(value: T): K {
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];
}
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
* @return {T[]} Array of items
*/
all(): T[] {
return deepCopy(Object.values(this.store));
all(expired?: boolean): CachedValue<T>[] {
return deepCopy<any>(Object.values(this.store)
.filter((v: any) => expired || !v._expired));
}
/**
@ -89,51 +111,85 @@ export class Cache<K extends string | number | symbol, T> {
/**
* Remove all keys from cache
*/
clear() {
this.store = <Record<K, T>>{};
clear(): this {
this.complete = false;
this.store = <any>{};
return this;
}
/**
* Delete an item from the cache
* @param {K} key Item's primary key
*/
delete(key: K) {
delete(key: K): this {
delete this.store[key];
if(this.options.storageKey && this.options.storage)
this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store));
this.save(key);
return this;
}
/**
* Return cache as an array of key-value pairs
* @return {[K, T][]} Key-value pairs array
*/
entries(): [K, T][] {
return <[K, T][]>Object.entries(this.store);
entries(expired?: boolean): [K, CachedValue<T>][] {
return deepCopy<any>(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
* @param {K} key Key to lookup
* @param expired Include expired items
* @return {T} Cached item
*/
get(key: K): T {
return deepCopy(this.store[key]);
get(key: K, expired?: boolean): CachedValue<T> | null {
const cached = deepCopy<any>(this.store[key] ?? null);
if(expired || !cached?._expired) return cached;
return null;
}
/**
* Get a list of cached keys
* @return {K[]} Array of keys
*/
keys(): K[] {
return <K[]>Object.keys(this.store);
keys(expired?: boolean): K[] {
return <K[]>Object.keys(this.store)
.filter(k => expired || !(<any>this.store)[k]._expired);
}
/**
* Get map of cached items
* @return {Record<K, T>}
*/
map(): Record<K, T> {
return deepCopy(this.store);
map(expired?: boolean): Record<K, CachedValue<T>> {
const copy: any = deepCopy(this.store);
if(!expired) Object.keys(copy).forEach(k => {
if(copy[k]._expired) delete copy[k]
});
return copy;
}
/**
@ -144,13 +200,13 @@ export class Cache<K extends string | number | symbol, T> {
* @return {this}
*/
set(key: K, value: T, ttl = this.options.ttl): this {
if(this.options.expiryPolicy == 'keep') delete (<any>value)._expired;
this.store[key] = value;
if(this.options.storageKey && this.options.storage)
this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store));
this.save(key);
if(ttl) setTimeout(() => {
this.complete = false;
this.delete(key);
}, ttl * 1000);
this.expire(key);
this.save(key);
}, (ttl || 0) * 1000);
return this;
}

View File

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

157
src/database.ts Normal file
View File

@ -0,0 +1,157 @@
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) {
const e = event.toString();
this.listeners[e] = (this.listeners[e] || []).filter(l => l === listener);
this.listeners[e] = (this.listeners[e] || []).filter(l => l != listener);
}
static on(event: any, listener: TypedListener) {
@ -43,7 +43,7 @@ export class TypedEmitter<T extends TypedEvents = TypedEvents> {
};
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]) {

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()) {
if(typeof date == 'number' || typeof date == 'string') date = new Date(date);
const timestamp = formatDate('YYYY-MM-DD_HH:mm:ss', date);
const timestamp = formatDate('YYYY-MM-DD_HH-mm', date);
return name ? name.replace('{{TIMESTAMP}}', timestamp) : timestamp;
}

View File

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

View File

@ -1,7 +1,23 @@
import {JSONAttemptParse} from './objects.ts';
/**
* Decode a JWT payload, this will not check for tampering so be careful
* Creates a JSON Web Token (JWT) using the provided payload.
*
* @param {object} payload The payload to include in the JWT.
* @param signature Add a JWT signature
* @return {string} The generated JWT string.
*/
export function createJwt(payload: object, signature = 'unsigned'): string {
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }))
.toString('base64url');
const body = Buffer.from(JSON.stringify(payload))
.toString('base64url');
// Signature is irrelevant for decodeJwt
return `${header}.${body}.${signature}`;
}
/**
* Decode a JSON Web Token (JWT) payload, this will not check for tampering so be careful
*
* @param {string} token JWT to decode
* @return {unknown} JWT payload

View File

@ -7,25 +7,30 @@
* ```
*
* @param {number} num Number to convert
* @param maxDen
* @return {string} Fraction with remainder
*/
export function dec2Frac(num: number) {
const gcd = (a: number, b: number): number => {
if (b < 0.0000001) return a;
return gcd(b, ~~(a % b));
};
const len = num.toString().length - 2;
let denominator = Math.pow(10, len);
let numerator = num * denominator;
const divisor = gcd(numerator, denominator);
numerator = ~~(numerator / divisor);
denominator = ~~(denominator / divisor)
const remainder = ~~(numerator / denominator);
numerator -= remainder * denominator;
return `${remainder ? remainder + ' ' : ''}${~~(numerator)}/${~~(denominator)}`;
export function dec2Frac(num: number, maxDen=1000): string {
let sign = Math.sign(num);
num = Math.abs(num);
if (Number.isInteger(num)) return (sign * num) + "";
let closest = { n: 0, d: 1, diff: Math.abs(num) };
for (let d = 1; d <= maxDen; d++) {
let n = Math.round(num * d);
let diff = Math.abs(num - n / d);
if (diff < closest.diff) {
closest = { n, d, diff };
if (diff < 1e-8) break; // Close enough
}
}
let integer = Math.floor(closest.n / closest.d);
let numerator = closest.n - integer * closest.d;
return (sign < 0 ? '-' : '') +
(integer ? integer + ' ' : '') +
(numerator ? numerator + '/' + closest.d : '');
}
/**
* Convert fraction to decimal number
*

View File

@ -1,6 +1,16 @@
import {PathEvent} from './path-events.ts';
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
* @param {object} args Map of key/value arguments
@ -26,14 +36,25 @@ export function gravatar(email: string, def='mp') {
}
/**
* Escape any regex special characters to avoid misinterpretation during search
*
* @param {string} value String which should be escaped
* @return {string} New escaped sequence
* Convert IPv6 to v4 because who uses that, NAT4Life
* @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
*/
export function escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&');
export function ipV6ToV4(ip: string) {
if(!ip) return null;
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;
/** Represents a function that can be called to unsubscribe from an event, stream, or observer */
export type Unsubscribe = () => void;

View File

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

View File

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

View File

@ -1,5 +1,14 @@
import {dotNotation, JSONAttemptParse} from './objects.ts';
import {dotNotation, JSONAttemptParse, JSONSerialize} from './objects.ts';
/**
* Filters an array of objects based on a search term and optional regex checking.
*
* @param {Array} rows Array of objects to filter
* @param {string} search The logic string or regext to filter on
* @param {boolean} [regex=false] Treat search expression as regex
* @param {Function} [transform=(r) => r] - Transform rows before filtering
* @return {Array} The filtered array of objects that matched search
*/
export function search(rows: any[], search: string, regex?: boolean, transform: Function = (r: any) => r) {
if(!rows) return [];
return rows.filter(r => {
@ -13,12 +22,18 @@ export function search(rows: any[], search: string, regex?: boolean, transform:
catch { return false; }
}).length
} else {
return testCondition(search, r);
return logicTest(r, search);
}
});
}
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 => {
switch(op) {
case '=':
@ -40,11 +55,11 @@ export function testCondition(condition: string, row: any) {
// Boolean operator
const prop = /(\S+)\s*(==?|!=|>=|>|<=|<)\s*(\S+)/g.exec(p);
if(prop) {
const key = Object.keys(row).find(k => k.toLowerCase() == prop[1].toLowerCase());
return evalBoolean(dotNotation<any>(row, key || prop[1]), prop[2], JSONAttemptParse(prop[3]));
const key = Object.keys(target).find(k => k.toLowerCase() == prop[1].toLowerCase());
return evalBoolean(dotNotation<any>(target, key || prop[1]), prop[2], JSONAttemptParse(prop[3]));
}
// Case-sensitive
const v = Object.values(row).map(v => typeof v == 'object' && v != null ? JSON.stringify(v) : v).join('');
const v = Object.values(target).map(JSONSerialize).join('');
if(/[A-Z]/g.test(condition)) return v.includes(p);
// Case-insensitive
return v.toLowerCase().includes(p);

View File

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

View File

@ -190,3 +190,35 @@ export async function sleepWhile(fn : () => boolean | Promise<boolean>, checkInt
export function timeUntil(date: Date | number): number {
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,20 +1,4 @@
/**
* Return keys on a type as an array of strings
*
* @example
* ```ts
* type Person = {
* firstName: string;
* lastName: string;
* age: number;
* }
*
* const keys = typeKeys<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>;
}
/** Mark all properties as writable */
export type Writable<T> = {
-readonly [P in keyof T]: T[P]
};

121
tests/arg-parser.spec.ts Normal file
View File

@ -0,0 +1,121 @@
import { Arg, ArgParser } from '../src';
describe('ArgParser', () => {
const basicArgs: Arg[] = [
{ name: 'input', desc: 'Input file' },
{ name: 'output', desc: 'Output file', default: 'out.txt' },
{ name: 'verbose', desc: 'Enable verbose mode', flags: ['-v', '--verbose'], default: false }
];
const commandArg = new ArgParser(
'subcmd',
'A sub command',
[{ name: 'foo', desc: 'Foo argument', optional: false }]
);
describe('constructor', () => {
it('should add commands and update examples', () => {
const parser = new ArgParser('main', 'desc', [commandArg, ...basicArgs], ['custom-example']);
expect(parser.commands[0].name).toBe('subcmd');
expect(parser.examples.some(e => typeof e === 'string' && e.includes('[OPTIONS] COMMAND'))).toBe(true);
expect(parser.examples).toEqual(expect.arrayContaining([
'custom-example',
expect.stringContaining('[OPTIONS]')
]));
});
});
describe('parse', () => {
it('should parse args and flags', () => {
const parser = new ArgParser('mycmd', 'desc', basicArgs);
const result = parser.parse(['file1.txt', '-v']);
expect(result.input).toBe('file1.txt');
expect(result.output).toBe('out.txt');
expect(result.verbose).toBe(true);
expect(result._extra).toEqual([]);
});
it('should handle missing required args and collect errors', () => {
const parser = new ArgParser('mycmd', 'desc', basicArgs);
const result = parser.parse([]);
expect(result._error).toContain('Argument missing: INPUT');
});
it('should handle default values correctly', () => {
const parser = new ArgParser('mycmd', 'desc', basicArgs);
const result = parser.parse(['file1.txt']);
expect(result.output).toBe('out.txt');
expect(result.verbose).toBe(false);
});
it('should parse flags with value assignment', () => {
const args: Arg[] = [
{ name: 'mode', desc: 'Mode', flags: ['-m', '--mode'], default: 'defaultMode' }
];
const parser = new ArgParser('mycmd', 'desc', args);
const result = parser.parse(['--mode=prod']);
expect(result.mode).toBe('prod');
});
it('should support extras collection', () => {
const args: Arg[] = [
{ name: 'main', desc: 'main', extras: true }
];
const parser = new ArgParser('cmd', 'desc', args);
const result = parser.parse(['a', 'b', 'c']);
expect(result.main).toEqual(['a', 'b', 'c']);
});
it('should handle unknown flags and put them to extras', () => {
const parser = new ArgParser('mycmd', 'desc', basicArgs);
const result = parser.parse(['file.txt', 'test', '--unknown']);
expect(result._extra).toContain('--unknown');
});
it('should handle subcommands and delegate parsing', () => {
const mainParser = new ArgParser('main', 'desc', [commandArg]);
const result = mainParser.parse(['subcmd', 'fooVal']);
expect(result._command).toBe('subcmd');
expect(result.foo).toBe('fooVal');
});
it('should parse combined short flags', () => {
const args: Arg[] = [
{ name: 'a', desc: 'Flag A', flags: ['-a'], default: false },
{ name: 'b', desc: 'Flag B', flags: ['-b'], default: false }
];
const parser = new ArgParser('mycmd', 'desc', args);
const result = parser.parse(['-ab']);
expect(result.a).toBe(true);
expect(result.b).toBe(true);
});
});
describe('help', () => {
it('should generate help with options and args', () => {
const parser = new ArgParser('mycmd', 'desc', basicArgs);
const helpMsg = parser.help();
expect(helpMsg).toContain('Input file');
expect(helpMsg).toContain('Enable verbose mode');
expect(helpMsg).toContain('-h, --help');
});
it('should generate help for a subcommand', () => {
const mainParser = new ArgParser('main', 'desc', [commandArg]);
const helpMsg = mainParser.help({ command: 'subcmd' });
expect(helpMsg).toContain('Foo argument');
});
it('should throw error for non-existent command', () => {
const mainParser = new ArgParser('main', 'desc', [commandArg]);
expect(() => mainParser.help({ command: 'notreal' })).toThrow();
});
it('should allow custom message override', () => {
const parser = new ArgParser('mycmd', 'desc', basicArgs);
const helpMsg = parser.help({ message: 'Custom!' });
expect(helpMsg).toContain('Custom!');
expect(helpMsg).not.toContain('desc');
});
});
});

View File

@ -1,82 +1,85 @@
import {addUnique, caseInsensitiveSort, flattenArr, sortByProp} from '../src';
import {addUnique, arrayDiff, caseInsensitiveSort, findByProp, flattenArr, makeArray, makeUnique, sortByProp,} from '../src';
describe('Array Utilities', () => {
describe('addUnique', () => {
const arr = [1, 2];
test('non-unique', () => {
addUnique(arr, 1);
expect(arr).toStrictEqual([1, 2]);
it('does not add duplicate value', () => {
const arr = [1, 2, 3];
addUnique(arr, 2);
expect(arr).toEqual([1, 2, 3]);
});
test('unique', () => {
it('adds unique value', () => {
const arr = [1, 2];
addUnique(arr, 3);
expect(arr).toStrictEqual([1, 2, 3]);
expect(arr).toEqual([1, 2, 3]);
});
});
describe('arrayDiff', () => {
it('returns unique elements present only in one array', () => {
expect(arrayDiff([1, 2, 3], [3, 4, 5]).toSorted()).toEqual([1, 2, 4, 5]);
});
it('returns empty array if arrays have the same elements', () => {
expect(arrayDiff([1, 2], [1, 2])).toEqual([]);
});
});
describe('caseInseFsitiveSort', () => {
it('sorts objects by string property case-insensitively', () => {
const arr = [{n: 'b'}, {n: 'A'}, {n: 'c'}];
arr.sort(caseInsensitiveSort('n'));
expect(arr.map(i => i.n)).toEqual(['A', 'b', 'c']);
});
});
describe('findByProp', () => {
it('filters objects by property value', () => {
const arr = [{name: 'foo'}, {name: 'bar'}];
const found = arr.filter(findByProp('name', 'foo'));
expect(found).toEqual([{name: 'foo'}]);
});
});
describe('flattenArr', () => {
test('flat array', () => expect(flattenArr([1, 2])).toStrictEqual([1, 2]));
test('2D array', () => expect(flattenArr([[1, 2], [3, 4]])).toStrictEqual([1, 2, 3, 4]));
test('3D array', () => expect(flattenArr([[[1, 2], [3, 4]], [[5, 6], [7, 8]]])).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8]));
test('mixed array', () => expect(flattenArr([1, 2, [3, 4], [[5, 6], [7, 8]]])).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8]));
it('flattens deeply nested arrays', () => {
const arr = [1, [2, [3, [4]], 5], 6];
expect(flattenArr(arr)).toEqual([1, 2, 3, 4, 5, 6]);
});
it('flattens flat array as-is', () => {
expect(flattenArr([1, 2, 3])).toEqual([1, 2, 3]);
});
});
describe('sortByProp', () => {
test('random letters', () => {
let unsorted: any = Array(100).fill(null)
.map(() => String.fromCharCode(Math.round(Math.random() * 25) + 97));
const sorted = unsorted.sort((a: any, b: any) => {
if(a > b) return 1;
if(a < b) return -1;
return 0;
}).map((l: any) => ({a: l}));
unsorted = unsorted.map((l: any) => ({a: l}));
expect(unsorted.sort(sortByProp('a'))).toStrictEqual(sorted);
it('sorts by numeric property', () => {
const arr = [{a: 3}, {a: 1}, {a: 2}];
arr.sort(sortByProp('a'));
expect(arr.map(i => i.a)).toEqual([1, 2, 3]);
});
test('random letters reversed', () => {
let unsorted: any = Array(100).fill(null)
.map(() => String.fromCharCode(Math.round(Math.random() * 25) + 97));
const sorted = unsorted.sort((a: any, b: any) => {
if(a > b) return -1;
if(a < b) return 1;
return 0;
}).map((n: any) => ({a: n}));
unsorted = unsorted.map((n: any) => ({a: n}));
expect(unsorted.sort(sortByProp('a', true))).toStrictEqual(sorted);
});
test('random numbers', () => {
let unsorted: any = Array(100).fill(null).map(() => Math.round(Math.random() * 100));
const sorted = unsorted.sort((a: any, b: any) => a - b).map((n: any) => ({a: n}));
unsorted = unsorted.map((n: any) => ({a: n}));
expect(unsorted.sort(sortByProp('a'))).toStrictEqual(sorted);
});
test('random numbers reversed', () => {
let unsorted: any = Array(100).fill(null).map(() => Math.round(Math.random() * 100));
const sorted = unsorted.sort((a: any, b: any) => b - a).map((n: any) => ({a: n}));
unsorted = unsorted.map((n: any) => ({a: n}));
expect(unsorted.sort(sortByProp('a', true))).toStrictEqual(sorted);
it('sorts by string property reversed', () => {
const arr = [{a: 'apple'}, {a: 'banana'}, {a: 'pear'}];
arr.sort(sortByProp('a', true));
expect(arr.map(i => i.a)).toEqual(['pear', 'banana', 'apple']);
});
});
describe('caseInsensitiveSort', () => {
test('non-string property', () => {
const unsorted: any = [{a: 'Apple', b: 123}, {a: 'Carrot', b: 789}, {a: 'banana', b: 456}];
const sorted: any = unsorted.map((u: any) => ({...u}));
expect(unsorted.sort(caseInsensitiveSort('b'))).toStrictEqual(sorted);
describe('makeUnique', () => {
it('removes duplicate primitives', () => {
const arr = [1, 2, 2, 3, 1];
expect(makeUnique(arr)).toEqual([1, 2, 3]);
});
test('simple strings', () => {
const unsorted: any = [{a: 'Apple'}, {a: 'Carrot'}, {a: 'banana'}];
const sorted: any = unsorted.sort((first: any, second: any) => {
return first.a.toLowerCase().localeCompare(second.a.toLowerCase());
}).map((u: any) => ({...u}));
expect(unsorted.sort(caseInsensitiveSort('a'))).toStrictEqual(sorted);
it('removes duplicate objects', () => {
const obj = {a: 1};
const arr = [obj, obj, {a: 1}];
expect(makeUnique(arr)).toHaveLength(1);
});
test('alphanumeric strings', () => {
const unsorted: any = [{a: '4pple'}, {a: 'Carrot'}, {a: 'b4n4n4'}];
const sorted: any = unsorted.sort((first: any, second: any) => {
return first.a.toLowerCase().localeCompare(second.a.toLowerCase());
}).map((u: any) => ({...u}));
expect(unsorted.sort(caseInsensitiveSort('a'))).toStrictEqual(sorted);
});
describe('makeArray', () => {
it('wraps non-arrays in array', () => {
expect(makeArray(1)).toEqual([1]);
});
it('returns array as-is', () => {
expect(makeArray([1, 2])).toEqual([1, 2]);
});
});
});

148
tests/aset.spec.ts Normal file
View File

@ -0,0 +1,148 @@
import {ASet} from '../src';
describe('ASet', () => {
describe('constructor', () => {
it('should create a set with unique elements', () => {
const set = new ASet([1, 2, 2, 3]);
expect(set.size).toBe(3);
expect(set.sort()).toEqual([1, 2, 3]);
});
it('should create an empty set by default', () => {
const set = new ASet();
expect(set.size).toBe(0);
});
});
describe('add', () => {
it('should add unique elements', () => {
const set = new ASet([1]);
set.add(2, 3, 1);
expect(set.sort()).toEqual([1, 2, 3]);
});
it('should return this', () => {
const set = new ASet();
expect(set.add(1)).toBe(set);
});
});
describe('clear', () => {
it('should remove all elements', () => {
const set = new ASet([1, 2]);
set.clear();
expect(set.size).toBe(0);
});
it('should return this', () => {
const set = new ASet([1]);
expect(set.clear()).toBe(set);
});
});
describe('delete', () => {
it('should remove specified elements', () => {
const set = new ASet([1, 2, 3]);
set.delete(2, 4);
expect(set.sort()).toEqual([1, 3]);
});
it('should return this', () => {
const set = new ASet([1]);
expect(set.delete(1)).toBe(set);
});
});
describe('difference', () => {
it('should return elements unique to this set', () => {
const setA = new ASet([1, 2, 3]);
const setB = new ASet([2, 4]);
expect(setA.difference(setB).sort()).toEqual([1, 3]);
expect(setB.difference(setA).sort()).toEqual([4]);
});
});
describe('has', () => {
it('should check if element exists in set', () => {
const set = new ASet([1, 2]);
expect(set.has(1)).toBe(true);
expect(set.has(99)).toBe(false);
});
});
describe('indexOf', () => {
it('should return correct index for primitive and object', () => {
const set = new ASet([{a: 1}, {b: 2}]);
expect(set.indexOf({a: 1})).toBe(0);
expect(set.indexOf(<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);
});
});
});

109
tests/cache.spec.ts Normal file
View File

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

40
tests/color.spec.ts Normal file
View File

@ -0,0 +1,40 @@
import {contrast} from '../src';
describe('contrast', () => {
it('should return "black" for white background', () => {
expect(contrast('ffffff')).toBe('black');
expect(contrast('#ffffff'.replace('#', ''))).toBe('black'); // simulate trimmed hash
});
it('should return "white" for black background', () => {
expect(contrast('000000')).toBe('white');
});
it('should return "white" for a dark color', () => {
expect(contrast('123456')).toBe('white');
expect(contrast('222222')).toBe('white');
});
it('should return "black" for a light color', () => {
expect(contrast('ffff99')).toBe('black');
expect(contrast('cccccc')).toBe('black');
});
it('should handle short hex color codes (3 chars)', () => {
expect(contrast('fff')).toBe('black');
expect(contrast('000')).toBe('white');
});
it('should return "black" for invalid input', () => {
expect(contrast('')).toBe('black');
expect(contrast('zzzzzz')).toBe('black');
expect(contrast('not-a-color')).toBe('black');
expect(contrast(undefined as unknown as string)).toBe('black');
expect(contrast(null as unknown as string)).toBe('black');
});
it('should handle hex codes with hash prefix if removed', () => {
expect(contrast('ededed')).toBe('black');
expect(contrast('343434')).toBe('white');
});
});

90
tests/csv.spec.ts Normal file
View File

@ -0,0 +1,90 @@
import {fromCsv, toCsv} from '../src';
describe('CSV Utilities', () => {
describe('fromCsv', () => {
it('parses CSV with headers', () => {
const input = `name,age,city
John,30,New York
Jane,25,Los Angeles`;
const expected = [
{name: 'John', age: '30', city: 'New York'},
{name: 'Jane', age: '25', city: 'Los Angeles'},
];
expect(fromCsv(input)).toEqual(expected);
});
it('parses CSV without headers', () => {
const input = `apple,red,1
banana,yellow,2`;
const expected = [
{A: 'apple', B: 'red', C: '1'},
{A: 'banana', B: 'yellow', C: '2'},
];
expect(fromCsv(input, false)).toEqual(expected);
});
it('handles quoted fields and commas', () => {
const input = `name,description
"Widget, Large","A large, useful widget"
Gadget,"A ""versatile"" gadget"`;
const expected = [
{name: 'Widget, Large', description: 'A large, useful widget'},
{name: 'Gadget', description: 'A "versatile" gadget'},
];
expect(fromCsv(input)).toEqual(expected);
});
it('handles empty fields', () => {
const input = `id,name,score
1,Tom,97
2,,89
3,Alice,`;
const expected = [
{id: '1', name: 'Tom', score: '97'},
{id: '2', name: '', score: '89'},
{id: '3', name: 'Alice', score: ''},
];
expect(fromCsv(input)).toEqual(expected);
});
});
describe('toCsv', () => {
it('converts array of objects to CSV', () => {
const arr = [
{name: 'John', age: 30, city: 'New York'},
{name: 'Jane', age: 25, city: 'Los Angeles'},
];
const csv = toCsv(arr);
expect(csv).toContain('name,age,city');
expect(csv).toContain('John,30,New York');
expect(csv).toContain('Jane,25,Los Angeles');
});
it('quotes fields with commas and quotes', () => {
const arr = [
{val: 'Comma, included', remark: 'needs, quotes'},
{val: 'Quote "double"', remark: 'embedded "quotes"'},
];
const csv = toCsv(arr);
expect(csv).toContain('"Comma, included","needs, quotes"');
expect(csv).toContain('"Quote ""double""","embedded ""quotes"""');
});
it('handles nested objects when flatten = true', () => {
const arr = [
{id: 1, info: {name: 'Alice', age: 20}},
{id: 2, info: {name: 'Bob', age: 22}}
];
const csv = toCsv(arr, true);
expect(csv).toMatch(/id,info\.name,info\.age/);
expect(csv).toMatch(/1,Alice,20/);
expect(csv).toMatch(/2,Bob,22/);
});
it('handles objects with array fields', () => {
const arr = [{name: 'Joe', tags: ['a', 'b']}];
const csv = toCsv(arr);
expect(csv).toContain('Joe,"[""a"",""b""]"');
});
});
});

118
tests/emitter.spec.ts Normal file
View File

@ -0,0 +1,118 @@
import {TypedEmitter} from '../src';
describe('TypedEmitter', () => {
describe('Instance', () => {
type Events = {
foo: (data: string) => void;
bar: (x: number, y: number) => void;
'*': (event: string, ...args: any[]) => void;
};
let emitter: TypedEmitter<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();
});
});
});

112
tests/errors.spec.ts Normal file
View File

@ -0,0 +1,112 @@
import {
CustomError,
BadRequestError,
UnauthorizedError,
PaymentRequiredError,
ForbiddenError,
NotFoundError,
MethodNotAllowedError,
NotAcceptableError,
InternalServerError,
NotImplementedError,
BadGatewayError,
ServiceUnavailableError,
GatewayTimeoutError,
errorFromCode
} from '../src';
describe('CustomError Hierarchy', () => {
it('CustomError basic properties and code getter/setter', () => {
const err = new CustomError('Test', 501);
expect(err.message).toBe('Test');
expect(err.code).toBe(501);
err.code = 404;
expect(err.code).toBe(404);
// default code if not provided
const noCodeError = new CustomError('No code');
expect(noCodeError.code).toBe(500);
});
it('CustomError static from method copies properties and stack', () => {
const orig: any = new Error('oops');
orig.code = 402;
orig.stack = 'FAKE_STACK';
const custom = CustomError.from(orig);
expect(custom).toBeInstanceOf(CustomError);
expect(custom.message).toBe('oops');
expect(custom.code).toBe(500);
expect(custom.stack).toBe('FAKE_STACK');
});
it('CustomError instanceof works', () => {
expect(CustomError.instanceof(new CustomError())).toBe(true);
expect(CustomError.instanceof(new Error())).toBe(false);
});
it('CustomError toString returns message', () => {
const err = new CustomError('foo');
expect(err.toString()).toBe('foo');
});
const cases = [
[BadRequestError, 400, 'Bad Request'],
[UnauthorizedError, 401, 'Unauthorized'],
[PaymentRequiredError, 402, 'Payment Required'],
[ForbiddenError, 403, 'Forbidden'],
[NotFoundError, 404, 'Not Found'],
[MethodNotAllowedError, 405, 'Method Not Allowed'],
[NotAcceptableError, 406, 'Not Acceptable'],
[InternalServerError, 500, 'Internal Server Error'],
[NotImplementedError, 501, 'Not Implemented'],
[BadGatewayError, 502, 'Bad Gateway'],
[ServiceUnavailableError, 503, 'Service Unavailable'],
[GatewayTimeoutError, 504, 'Gateway Timeout'],
] as const;
describe.each(cases)(
'%p (code=%i, defaultMessage="%s")',
(ErrClass, code, defMsg) => {
it('has static code, default message, and instanceof', () => {
const e = new ErrClass();
expect(e).toBeInstanceOf(ErrClass);
expect(e.code).toBe(code);
expect(e.message).toBe(defMsg);
expect(ErrClass.instanceof(e)).toBe(true);
});
it('supports custom messages', () => {
const msg = 'Custom msg';
const e = new ErrClass(msg);
expect(e.message).toBe(msg);
});
}
);
describe('errorFromCode', () => {
it.each(cases)(
'returns %p for code %i',
(ErrClass, code, defMsg) => {
const err = errorFromCode(code);
expect(err).toBeInstanceOf(ErrClass);
expect(err.code).toBe(code);
expect(err.message).toBe(defMsg);
}
);
it('overrides message if provided', () => {
const err = errorFromCode(404, 'Nope');
expect(err).toBeInstanceOf(NotFoundError);
expect(err.message).toBe('Nope');
});
it('fallbacks to CustomError for unknown codes', () => {
const err = errorFromCode(999, 'xyz');
expect(err).toBeInstanceOf(CustomError);
expect(err.code).toBe(999);
expect(err.message).toBe('xyz');
});
it('handles missing message gracefully', () => {
const err = errorFromCode(555);
expect(err).toBeInstanceOf(CustomError);
expect(err.code).toBe(555);
});
});
});

61
tests/jwt.spec.ts Normal file
View File

@ -0,0 +1,61 @@
import { createJwt, decodeJwt } from '../src';
describe('JWT Utilities', () => {
describe('createJwt', () => {
it('should create a valid JWT string with default signature', () => {
const payload = { foo: 'bar', num: 123 };
const jwt = createJwt(payload);
const parts = jwt.split('.');
expect(parts).toHaveLength(3);
// Header should decode to HS256 + JWT
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
expect(header).toEqual({ alg: "HS256", typ: "JWT" });
// Body should match the payload
const body = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
expect(body).toEqual(payload);
// Signature should be 'unsigned'
expect(parts[2]).toBe('unsigned');
});
it('should allow custom signature', () => {
const jwt = createJwt({ test: 1 }, 'mysignature');
expect(jwt.split('.')[2]).toBe('mysignature');
});
});
describe('decodeJwt', () => {
it('should decode a JWT payload', () => {
const payload = { user: 'alice', age: 30 };
const jwt = createJwt(payload);
const decoded = decodeJwt<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);
});
});
});

51
tests/math.spec.ts Normal file
View File

@ -0,0 +1,51 @@
import { dec2Frac, fracToDec } from '../src';
describe('Math Utilities', () => {
describe('dec2Frac', () => {
it('should convert decimal to fraction with whole and remainder', () => {
expect(dec2Frac(1.25)).toBe('1 1/4');
expect(dec2Frac(2.5)).toBe('2 1/2');
expect(dec2Frac(3.75)).toBe('3 3/4');
});
it('should convert integer to fraction with denominator', () => {
expect(dec2Frac(4)).toBe('4');
expect(dec2Frac(0)).toBe('0');
});
it('should convert proper fraction (less than 1)', () => {
expect(dec2Frac(0.75)).toBe('3/4');
expect(dec2Frac(0.5)).toBe('1/2');
expect(dec2Frac(0.1)).toBe('1/10');
});
it('should handle repeating decimals gracefully', () => {
expect(dec2Frac(0.333333)).toBe('1/3');
expect(dec2Frac(0.666666)).toBe('2/3');
});
});
describe('fracToDec', () => {
it('should convert mixed fraction to decimal', () => {
expect(fracToDec('1 1/4')).toBeCloseTo(1.25);
expect(fracToDec('2 1/2')).toBeCloseTo(2.5);
expect(fracToDec('3 3/4')).toBeCloseTo(3.75);
});
it('should convert fraction without whole part to decimal', () => {
expect(fracToDec('3/4')).toBeCloseTo(0.75);
expect(fracToDec('1/2')).toBeCloseTo(0.5);
expect(fracToDec('1/10')).toBeCloseTo(0.1);
});
it('should convert whole number fraction', () => {
expect(fracToDec('4 0/1')).toBeCloseTo(4);
expect(fracToDec('0/1')).toBeCloseTo(0);
});
it('should handle zero correctly', () => {
expect(fracToDec('0/1')).toBeCloseTo(0);
expect(fracToDec('0 0/1')).toBeCloseTo(0);
});
});
});

View File

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

View File

@ -1,89 +1,164 @@
import {clean, deepCopy, dotNotation, flattenObj, includes, isEqual} from "../src";
describe('Object Utilities', () => {
const TEST_OBJECT = {
a: 1,
b: [
[2, 3],
[4, 5]
],
c: {
d: [
[{e: 6, f: 7}]
],
},
g: {h: 8},
i: () => 9
};
import {
clean, deepCopy, deepMerge, dotNotation, encodeQuery, flattenObj, formData, includes, isEqual, mixin,
JSONAttemptParse, JSONSerialize, JSONSanitize
} from '../src';
describe('Object utilities', () => {
describe('clean', () => {
test('remove null properties', () => {
const a = {a: 1, b: null, c: undefined};
const final = {a: 1};
expect(clean(a)).toEqual(final);
it('removes null values', () => {
const obj = { a: 1, b: null, c: undefined, d: 0 };
expect(clean({ ...obj })).toEqual({ a: 1, c: undefined, d: 0 });
});
test('remove undefined properties', () => {
const a = {a: 1, b: null, c: undefined};
const final = {a: 1, b: null};
expect(clean(a, true)).toEqual(final);
it('throws on null input', () => {
expect(() => clean(null as any)).toThrow();
});
it('removes undefined only when specified', () => {
const obj = { a: 1, b: undefined, c: null };
expect(clean({ ...obj }, true)).toEqual({ a: 1, c: null });
});
it('works for arrays', () => {
expect(clean([1, null, 2, undefined, 3] as any)).toEqual([1, 2, 3]);
});
});
describe('deepCopy', () => {
it('creates a deep copy', () => {
const obj = { a: { b: 2 } };
const copy = deepCopy(obj);
expect(copy).toEqual(obj);
expect(copy).not.toBe(obj);
expect(copy.a).not.toBe(obj.a);
});
});
describe('deepMerge', () => {
it('merges deeply nested objects', () => {
const tgt = { a: { b: 1 }, d: 7 };
const src = { a: { c: 2 }, d: 8 };
expect(deepMerge({ ...tgt }, src)).toEqual({ a: { b: 1, c: 2 }, d: 8 });
});
it('merges multiple sources', () => {
const t = { a: 1 };
const s1 = { b: 2 };
const s2 = { c: 3 };
expect(deepMerge({ ...t }, s1, s2)).toEqual({ a: 1, b: 2, c: 3 });
});
});
describe('dotNotation', () => {
test('no object or properties', () => {
expect(dotNotation(undefined, 'z')).toStrictEqual(undefined);
expect(dotNotation(TEST_OBJECT, '')).toStrictEqual(undefined);
it('gets nested value', () => {
const obj = { a: { b: { c: 3 } } };
expect(dotNotation(obj, 'a.b.c')).toBe(3);
});
test('invalid property', () => expect(dotNotation(TEST_OBJECT, 'z')).toBeUndefined());
test('by property', () => expect(dotNotation(TEST_OBJECT, 'a')).toBe(TEST_OBJECT.a));
test('by key', () => expect(dotNotation(TEST_OBJECT, '["a"]')).toBe(TEST_OBJECT['a']));
test('by key (single quote)', () => expect(dotNotation(TEST_OBJECT, '[\'a\']')).toBe(TEST_OBJECT['a']));
test('by key (double quote)', () => expect(dotNotation(TEST_OBJECT, '["a"]')).toBe(TEST_OBJECT['a']));
test('by index', () => expect(dotNotation(TEST_OBJECT, 'b[0]')).toBe(TEST_OBJECT.b[0]));
test('by index (2d)', () => expect(dotNotation(TEST_OBJECT, 'b[1][1]')).toBe(TEST_OBJECT.b[1][1]));
test('everything combined', () => expect(dotNotation(TEST_OBJECT, 'c["d"][0][0].e'))
.toBe(TEST_OBJECT.c['d'][0][0].e));
test('set value', () => {
const COPY = JSON.parse(JSON.stringify(TEST_OBJECT));
dotNotation(COPY, 'c["d"][0][0].e', 'test');
expect(COPY['c']['d'][0][0]['e']).toBe('test');
it('sets nested value', () => {
const obj = { a: { b: { c: 3 } } };
dotNotation(obj, 'a.b.c', 10);
expect(obj.a.b.c).toBe(10);
});
test('set new value', () => {
const COPY = JSON.parse(JSON.stringify(TEST_OBJECT));
dotNotation(COPY, 'c.x.y.z', 'test');
expect(COPY['c']['x']['y']['z']).toBe('test');
it('returns undefined for non-existent path', () => {
expect(dotNotation({ a: 1 }, 'a.b.c')).toBeUndefined();
});
it('creates nested object when setting', () => {
const obj: any = {};
dotNotation(obj, 'd.e.f', 5);
expect(obj.d.e.f).toBe(5);
});
});
describe('encodeQuery', () => {
it('encodes simple objects', () => {
expect(encodeQuery({ a: 1, b: 'test' })).toBe('a=1&b=test');
});
it('handles special characters', () => {
expect(encodeQuery({ a: 'hello world' })).toBe('a=hello%20world');
});
});
describe('flattenObj', () => {
it('flattens nested objects', () => {
const obj = { a: { b: 2 }, c: 3 };
expect(flattenObj(obj)).toEqual({ 'a.b': 2, c: 3 });
});
it('handles multiple nesting', () => {
const obj = { a: { b: { c: 4 } } };
expect(flattenObj(obj)).toEqual({ 'a.b.c': 4 });
});
});
describe('formData', () => {
it('converts object to FormData', () => {
const obj = { a: '1', b: 'foo' };
const fd = formData(obj);
expect(fd.get('a')).toBe('1');
expect(fd.get('b')).toBe('foo');
});
});
describe('includes', () => {
test('simple', () => expect(includes(TEST_OBJECT, {a: 1})).toBeTruthy());
test('nested', () => expect(includes(TEST_OBJECT, {g: {h: 8}})).toBeTruthy());
test('array', () => expect(includes(TEST_OBJECT, {b: [[2]]})).toBeTruthy());
test('nested array', () => expect(includes(TEST_OBJECT, {a: 1, c: {d: [[{e: 6}]]}})).toBeTruthy());
test('wong nested array', () => expect(includes(TEST_OBJECT, {a: 1, c: {d: [{e: 7}]}})).toBeFalsy());
test('wrong value', () => expect(includes(TEST_OBJECT, {a: 1, b: 2})).toBeFalsy());
test('missing value', () => expect(includes(TEST_OBJECT, {a: 1, i: 10})).toBeFalsy());
it('checks if all values included', () => {
expect(includes({ a: 2, b: 3 }, { a: 2 })).toBeTruthy();
expect(includes({ a: 2, b: 3 }, { c: 1 })).toBeFalsy();
});
it('handles arrays of values', () => {
expect(includes([{ a: 1 }], [{ a: 1 }])).toBeTruthy();
expect(includes([{ a: 1 }], [{ a: 2 }])).toBeFalsy();
});
it('allows missing when specified', () => {
expect(includes(undefined, { a: 2 }, true)).toBeTruthy();
});
});
describe('isEqual', () => {
test('boolean equal', () => expect(isEqual(true, true)).toBeTruthy());
test('boolean not-equal', () => expect(isEqual(true, false)).toBeFalsy());
test('number equal', () => expect(isEqual(1, 1)).toBeTruthy());
test('number not-equal', () => expect(isEqual(1, 0)).toBeFalsy());
test('string equal', () => expect(isEqual('abc', 'abc')).toBeTruthy());
test('string not-equal', () => expect(isEqual('abc', '')).toBeFalsy());
test('array equal', () => expect(isEqual([true, 1, 'a'], [true, 1, 'a'])).toBeTruthy());
test('array not-equal', () => expect(isEqual([true, 1, 'a'], [1])).toBeFalsy());
test('object equal', () => expect(isEqual({a: 1, b: 2}, {a: 1, b: 2})).toBeTruthy());
test('object not-equal', () => expect(isEqual({a: 1, b: 2}, {a: 1})).toBeFalsy());
test('complex', () => expect(isEqual(TEST_OBJECT, TEST_OBJECT)).toBeTruthy());
it('returns true for deeply equal objects', () => {
expect(isEqual({ a: 1, b: { c: 3 } }, { a: 1, b: { c: 3 } })).toBe(true);
});
it('returns false for non-equal objects', () => {
expect(isEqual({ a: 1 }, { a: 2 })).toBe(false);
});
it('compares functions by string', () => {
expect(isEqual(() => 1, () => 1)).toBe(true);
});
});
describe('flattenObj', () => {
test('simple nested object', () => expect(flattenObj({a: {b: {c: 1}}})).toEqual({"a.b.c": 1}));
test('already flat object', () => expect(flattenObj(TEST_OBJECT['g'])).toEqual(TEST_OBJECT['g']));
test('non-object input', () => expect(flattenObj(TEST_OBJECT['b'])).toBeUndefined());
test('complex nested object', () => expect(flattenObj({a: 1, b: {c: 2}, d: {e: {f: {g: 3}}}}))
.toEqual({"a": 1, "b.c": 2, "d.e.f.g": 3}));
describe('mixin', () => {
it('merges prototypes', () => {
class A { foo() { return 1; } }
class B { bar() { return 2; } }
class C {}
mixin(C, [A, B]);
const c = new (C as any)();
expect(c.foo()).toBe(1);
expect(c.bar()).toBe(2);
});
});
describe('JSONAttemptParse', () => {
it('parses valid JSON', () => {
expect(JSONAttemptParse('{"a":1}')).toEqual({ a: 1 });
});
it('returns original string on error', () => {
expect(JSONAttemptParse('not json')).toBe('not json');
});
});
describe('JSONSerialize', () => {
it('serializes objects', () => {
expect(JSONSerialize({ a: 1 })).toBe(JSON.stringify({ a: 1 }));
});
it('leaves primitives as is', () => {
expect(JSONSerialize('test')).toBe('test');
expect(JSONSerialize(123)).toBe(123);
});
});
describe('JSONSanitize', () => {
it('stringifies objects', () => {
expect(JSONSanitize({ a: 1 })).toBe(JSON.stringify({ a: 1 }));
});
it('does not throw on circular refs', () => {
const obj: any = {};
obj.self = obj;
expect(() => JSONSanitize(obj)).not.toThrow();
});
});
});

198
tests/path-events.spec.ts Normal file
View File

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

71
tests/search.spec.ts Normal file
View File

@ -0,0 +1,71 @@
import {logicTest, search} from '../src';
const rows = [
{id: 1, name: 'Alice', age: 30},
{id: 2, name: 'Bob', age: 24},
{id: 3, name: 'Carol', age: 30},
];
describe('Search Utilities', () => {
describe('search', () => {
it('returns empty array for null rows', () => {
expect(search(null as any, 'test')).toEqual([]);
});
it('returns all rows if search is empty', () => {
expect(search(rows, '')).toEqual(rows);
});
it('filters based on a simple property string', () => {
expect(search(rows, 'Alice')).toEqual([rows[0]]);
});
it('filters using regex when regex=true', () => {
expect(search(rows, '^B', true)).toEqual([rows[1]]);
});
it('applies the transform function before filtering', () => {
const transform = (r: any) => ({...r, name: r.name.toLowerCase()});
expect(search(rows, 'alice', false, transform)).toEqual([rows[0]]);
});
it('uses logicTest for non-regex search', () => {
expect(search(rows, 'age == 30')).toEqual([rows[0], rows[2]]);
expect(search(rows, 'id = 2')).toEqual([rows[1]]);
});
it('returns all if search is falsy and regex enabled', () => {
expect(search(rows, '', true)).toEqual(rows);
});
});
describe('logicTest', () => {
const obj = {x: 10, y: 5, name: 'Alpha'};
it('handles equality and inequality', () => {
expect(logicTest(obj, 'x == 10')).toBe(true);
expect(logicTest(obj, 'y != 5')).toBe(false);
});
it('handles comparison operators', () => {
expect(logicTest(obj, 'x > 5')).toBe(true);
expect(logicTest(obj, 'y <= 10')).toBe(true);
expect(logicTest(obj, 'x < 5')).toBe(false);
});
it('supports case insensitive property search', () => {
expect(logicTest(obj, 'alpha')).toBeTruthy();
expect(logicTest(obj, 'ALPHA')).toBeFalsy();
});
it('handles logical AND/OR expressions', () => {
expect(logicTest(obj, 'x == 10 && y == 5')).toBe(true);
expect(logicTest(obj, 'x == 10 || y == 100')).toBe(true);
expect(logicTest(obj, 'x == 1 && y == 5')).toBe(false);
});
it('returns false for unsupported operators', () => {
expect(logicTest(obj, 'x === 10')).toBe(false);
});
});
});

View File

@ -1,50 +1,168 @@
import {matchAll, randomString, randomStringBuilder} from "../src";
import {
camelCase,
CHAR_LIST,
formatBytes,
formatPhoneNumber,
insertAt, kebabCase,
LETTER_LIST, matchAll, md5,
NUMBER_LIST, pad, parseUrl, pascalCase, randomHex, randomString, randomStringBuilder, snakeCase, strSplice,
SYMBOL_LIST
} from '../src';
describe('String Utilities', () => {
describe('randomString', () => {
test('length', () => expect(randomString(32).length).toStrictEqual(32));
test('distribution', () => {
const charList = '123';
const random = randomString(32, charList);
expect(random.split('').filter(c => c == '1').length).toBeGreaterThan(0);
expect(random.split('').filter(c => c == '2').length).toBeGreaterThan(0);
expect(random.split('').filter(c => c == '3').length).toBeGreaterThan(0);
test('LETTER_LIST, NUMBER_LIST, SYMBOL_LIST, CHAR_LIST', () => {
expect(LETTER_LIST).toBe('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
expect(NUMBER_LIST).toBe('0123456789');
expect(SYMBOL_LIST).toContain('@');
expect(CHAR_LIST).toContain('A');
expect(CHAR_LIST).toContain('a');
expect(CHAR_LIST).toContain('5');
expect(CHAR_LIST).toContain('!');
});
test('binary', () => {
const randomByte = randomString(8, '01');
expect(randomByte.split('').filter(c => c == '0').length).toBeGreaterThan(0);
expect(randomByte.split('').filter(c => c == '1').length).toBeGreaterThan(0);
expect(randomByte.length).toStrictEqual(8);
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', () => {
it('creates a random string from CHAR_LIST of correct length', () => {
const s = randomString(10);
expect(s).toHaveLength(10);
// letters, numbers, symbols all included in CHAR_LIST
});
it('uses provided pool', () => {
expect(['0','1']).toContain(randomString(1, '01'));
});
});
describe('randomStringBuilder', () => {
test('length', () => {
const len = ~~(Math.random() * 32);
expect(randomStringBuilder(len, true).length).toStrictEqual(len);
it('creates with just letters', () => {
expect(/^[A-Z]+$/.test(randomStringBuilder(5, true, false, false))).toBe(true);
});
test('no length', () => {
expect(randomStringBuilder(0, true)).toStrictEqual('');
it('creates with just numbers', () => {
expect(/^[0-9]+$/.test(randomStringBuilder(5, false, true, false))).toBe(true);
});
test('letters only', () =>
expect(/^[a-zA-Z]{10}$/g.test(randomStringBuilder(10, true))).toBeTruthy());
test('numbers only', () =>
expect(/^[0-9]{10}$/g.test(<any>randomStringBuilder(10, false, true))).toBeTruthy());
test('symbols only', () =>
expect(/^[^a-zA-Z0-9]{10}$/g.test(randomStringBuilder(10, false, false, true))).toBeTruthy());
test('everything', () => {
const randomString = randomStringBuilder(30, true, true, true);
expect(/[a-zA-Z]/g.test(randomString)).toBeTruthy();
expect(/[0-9]/g.test(randomString)).toBeTruthy();
expect(/[^a-zA-Z0-9]/g.test(randomString)).toBeTruthy();
it('creates with just symbols', () => {
expect(SYMBOL_LIST).toContain(randomStringBuilder(1, false, false, true));
});
it('throws if all false', () => {
expect(() => randomStringBuilder(5, false, false, false)).toThrow();
});
});
describe('snakeCase', () => {
it('converts to snake_case', () => {
expect(snakeCase('helloWorld')).toContain('hello_world');
expect(snakeCase('')).toBe('');
});
});
describe('strSplice', () => {
it('splices string as expected', () => {
expect(strSplice('abcdef', 2, 2, 'ZZ')).toBe('abZZef');
expect(strSplice('abcdef', 1, 0, 'Z')).toBe('aZbcdef');
});
test('no pool', () =>
expect(() => randomStringBuilder(10, false, false, false)).toThrow());
});
describe('matchAll', () => {
test('using string', () => expect(matchAll('fooBar fooBar FooBar', 'fooBar').length).toBe(2));
test('using regex', () => expect(matchAll('fooBar fooBar FooBar', /fooBar/g).length).toBe(2));
test('using malformed regex', () => expect(() => matchAll('fooBar fooBar FooBar', /fooBar/)).toThrow());
it('returns expected matches', () => {
const matches = matchAll('a1 b2 c3', /\d/g);
expect(matches.length).toBe(3);
expect(matches[0][0]).toBe('1');
});
it('throws for non-global regex', () => {
expect(() => matchAll('abc', /a/)).toThrow();
});
it('accepts regex string', () => {
const matches = matchAll('a1a2', '\\d');
expect(matches.length).toBe(2);
});
});
describe('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);
});
});
});

108
tests/time.spec.ts Normal file
View File

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

18
tests/type.spec.ts Normal file
View File

@ -0,0 +1,18 @@
import {Writable} from '../src';
describe('Type Utilities', () => {
describe('Writable', () => {
it('should create a writable version of a readonly type', () => {
type ReadonlyPerson = {
readonly name: string;
readonly age: number;
};
type WritablePerson = Writable<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"],
"compilerOptions": {
"target": "ES2020",
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],