diff --git a/README.md b/README.md index 81dbbd1..dd81c53 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,12 @@ -# @jsonjoy.com/json-random +# `json-random` + +The `json-random` library lets you generate random JSON values. + +- `randomString` - generates a random string following a string token template. +- `TemplateJson` - generates random JSON following a template. +- `RandomJson` - generates random JSON by generating new nodes and inserting them into random positions in the JSON. +- `int` - generates a random integer. +- `deterministic(seed, () => {})` - fixates `Math.random()` such that code in callback generates a deterministic value. ## Use Cases @@ -25,12 +33,18 @@ const optimizedFunction = codegen.compile(); ``` -# json-random +## Reference -The `json-random` library lets you generate random JSON values. +### `randomString` + +TODO: ... + + +### `TemplateJson` +TODO: ... -## Usage +### `RandomJson` Generate a random JSON object. diff --git a/package.json b/package.json index 2629864..2921da0 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,9 @@ "peerDependencies": { "tslib": "2" }, - "dependencies": {}, + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0" + }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@types/benchmark": "^2.1.2", diff --git a/src/RandomJson.ts b/src/RandomJson.ts index 00f0e6b..3fb5711 100644 --- a/src/RandomJson.ts +++ b/src/RandomJson.ts @@ -1,4 +1,4 @@ -import {randomString, Token} from './string'; +import {randomString, type Token} from './string'; type JsonValue = unknown; @@ -170,7 +170,7 @@ export class RandomJson { : Math.random() < 0.2 ? Math.round(0xffff * (2 * Math.random() - 1)) : Math.round(Number.MAX_SAFE_INTEGER * (2 * Math.random() - 1)); - if (num === -0) return 0; + if (num === 0) return 0; return num; } diff --git a/src/__demos__/map-demo.ts b/src/__demos__/map-demo.ts new file mode 100644 index 0000000..289d799 --- /dev/null +++ b/src/__demos__/map-demo.ts @@ -0,0 +1,56 @@ +/** + * Run with: + * + * npx ts-node src/__demos__/map-demo.ts + */ + +import {TemplateJson} from '../structured/TemplateJson'; + +console.log('=== Map Template Demo ===\n'); + +// Basic map usage +console.log('1. Basic map with shorthand:'); +const basicMap = TemplateJson.gen('map'); +console.log(JSON.stringify(basicMap, null, 2)); + +// Map with custom key tokens and values +console.log('\n2. Map with custom user IDs and profile data:'); +const userMap = TemplateJson.gen([ + 'map', + ['list', 'user_', ['pick', '001', '002', '003', '004', '005']], + [ + 'obj', + [ + ['name', ['str', ['list', ['pick', 'John', 'Jane', 'Bob', 'Alice'], ' ', ['pick', 'Doe', 'Smith', 'Johnson']]]], + ['age', ['int', 18, 65]], + ['active', 'bool'], + ], + ], + 2, + 4, +]); +console.log(JSON.stringify(userMap, null, 2)); + +// Map with complex nested structures +console.log('\n3. Map with API endpoints and their configurations:'); +const apiMap = TemplateJson.gen([ + 'map', + ['list', 'api/', ['pick', 'users', 'posts', 'comments', 'auth']], + [ + 'obj', + [ + ['method', ['str', ['pick', 'GET', 'POST', 'PUT', 'DELETE']]], + ['timeout', ['int', 1000, 5000]], + ['retries', ['int', 0, 3]], + ['auth_required', 'bool'], + ], + ], + 3, + 3, +]); +console.log(JSON.stringify(apiMap, null, 2)); + +// Map with guaranteed size +console.log('\n4. Map with exactly 2 entries:'); +const fixedMap = TemplateJson.gen(['map', ['pick', 'key1', 'key2', 'key3'], ['or', 'str', 'int', 'bool'], 2, 2]); +console.log(JSON.stringify(fixedMap, null, 2)); diff --git a/src/__tests__/RandomJson.spec.ts b/src/__tests__/RandomJson.spec.ts index a4a9983..b6a0d5d 100644 --- a/src/__tests__/RandomJson.spec.ts +++ b/src/__tests__/RandomJson.spec.ts @@ -124,13 +124,7 @@ test('random strings can be converted to UTF-8', () => { test('can specify string generation schema', () => { const str = RandomJson.generate({ rootNode: 'string', - strings: [ - 'list', - [ - ['repeat', 2, 2, 'xx'], - ['pick', ['y']], - ], - ], + strings: ['list', ['repeat', 2, 2, 'xx'], ['pick', 'y']], }); expect(str).toBe('xxxxy'); }); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..1a61d1d --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,6 @@ +export const resetMathRandom = (seed = 123456789) => { + Math.random = () => { + seed = (seed * 48271) % 2147483647; + return (seed - 1) / 2147483646; + }; +}; diff --git a/src/__tests__/string.spec.ts b/src/__tests__/string.spec.ts index 34ae89a..d907d83 100644 --- a/src/__tests__/string.spec.ts +++ b/src/__tests__/string.spec.ts @@ -1,41 +1,50 @@ -import {randomString, Token} from '../string'; +import {randomString, type Token} from '../string'; +import {deterministic} from '../util'; -// Tests for randomString describe('randomString', () => { it('should pick a random string from the array', () => { - const token: Token = ['pick', ['apple', 'banana', 'cherry']]; + const token: Token = ['pick', 'apple', 'banana', 'cherry']; const result = randomString(token); expect(['apple', 'banana', 'cherry']).toContain(result); }); it('should repeat a pattern a random number of times', () => { - const token: Token = ['repeat', 2, 5, ['pick', ['x', 'y', 'z', ' ']]]; + const token: Token = ['repeat', 2, 5, ['pick', 'x', 'y', 'z', ' ']]; const result = randomString(token); expect(result.length).toBeGreaterThanOrEqual(2); expect(result.length).toBeLessThanOrEqual(5); }); it('should pick a random character from the Unicode range', () => { - const token: Token = ['range', 65, 90]; // A-Z + const token: Token = ['char', 65, 90]; // A-Z const result = randomString(token); expect(result).toMatch(/^[A-Z]$/); }); - // test tlist token + it('should pick a random character from the Unicode range three times', () => { + const token: Token = ['char', 65, 90, 3]; // A-Z + const result = randomString(token); + expect(result).toMatch(/^[A-Z]{3}$/); + }); + it('executes a list of tokens', () => { const token: Token = [ 'list', - [ - ['pick', ['monkey', 'dog', 'cat']], - ['pick', [' ']], - ['pick', ['ate', 'threw', 'picked']], - ['pick', [' ']], - ['pick', ['apple', 'banana', 'cherry']], - ], + ['pick', 'monkey', 'dog', 'cat'], + ['pick', ' '], + ['pick', 'ate', 'threw', 'picked'], + ['pick', ' '], + ['pick', 'apple', 'banana', 'cherry'], ]; const result = randomString(token); expect(/monkey|dog|cat/.test(result)).toBe(true); expect(/ate|threw|picked/.test(result)).toBe(true); expect(/apple|banana|cherry/.test(result)).toBe(true); }); + + it('can nest picks', () => { + const token: Token = ['pick', ['pick', 'monkey', 'dog', 'cat'], ['pick', 'banana', 'apple']]; + const str = deterministic(123, () => randomString(token)); + expect(str).toBe('dog'); + }); }); diff --git a/src/index.ts b/src/index.ts index 8cd775e..c4278ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,5 @@ +export {deterministic, rnd} from './util'; export * from './RandomJson'; +export * from './number'; export * from './string'; +export * from './structured'; diff --git a/src/number.ts b/src/number.ts new file mode 100644 index 0000000..c0ee995 --- /dev/null +++ b/src/number.ts @@ -0,0 +1,5 @@ +export const int = (min: number, max: number): number => { + let int = Math.round(Math.random() * (max - min) + min); + int = Math.max(min, Math.min(max, int)); + return int; +}; diff --git a/src/string.ts b/src/string.ts index 5c2f2c6..77034ec 100644 --- a/src/string.ts +++ b/src/string.ts @@ -1,7 +1,7 @@ /** * Tokens used to specify random string generation options */ -export type Token = TokenLiteral | TokenPick | TokenRepeat | TokenRange | TokenList; +export type Token = TokenLiteral | TokenPick | TokenRepeat | TokenChar | TokenList; /** * A string literal to use as-is. @@ -9,10 +9,9 @@ export type Token = TokenLiteral | TokenPick | TokenRepeat | TokenRange | TokenL export type TokenLiteral = string; /** - * Picks a random string from the provided array of strings. - * The `from` array can contain any number of strings. + * Picks a random token from the provided tokens. */ -export type TokenPick = [type: 'pick', from: string[]]; +export type TokenPick = [type: 'pick', ...from: Token[]]; /** * Repeats `pattern` a random number of times between `min` and `max`. @@ -21,13 +20,14 @@ export type TokenRepeat = [type: 'repeat', min: number, max: number, pattern: To /** * Specifies a Unicode code point range from which to pick a random character. + * The `count` parameter specifies how many characters to pick, defaults to 1. */ -export type TokenRange = [type: 'range', min: number, max: number]; +export type TokenChar = [type: 'char', min: number, max: number, count?: number]; /** - * Executes a list of `what` tokens in sequence. + * Executes a list of `every` tokens in sequence. */ -export type TokenList = [type: 'list', what: Token[]]; +export type TokenList = [type: 'list', ...every: Token[]]; /** * Generates a random string based on the provided token. @@ -39,26 +39,29 @@ export function randomString(token: Token): string { const rnd = Math.random(); switch (token[0]) { case 'pick': { - const set = token[1]; - return set[Math.floor(rnd * set.length)]; + const [, ...from] = token; + return randomString(from[Math.floor(rnd * from.length)]); } case 'repeat': { - const min = token[1]; - const max = token[2]; - const pattern = token[3]; + const [, min, max, pattern] = token; const count = Math.floor(rnd * (max - min + 1)) + min; let str = ''; for (let i = 0; i < count; i++) str += randomString(pattern); return str; } - case 'range': { - const min = token[1]; - const max = token[2]; - const codePoint = Math.floor(rnd * (max - min + 1)) + min; - return String.fromCodePoint(codePoint); + case 'char': { + const [, min, max, count = 1] = token; + let str = ''; + for (let i = 0; i < count; i++) { + const codePoint = Math.floor(rnd * (max - min + 1)) + min; + str += String.fromCodePoint(codePoint); + } + return str; + } + case 'list': { + const [, ...every] = token; + return every.map(randomString).join(''); } - case 'list': - return token[1].map(randomString).join(''); default: throw new Error('Invalid token type'); } diff --git a/src/structured/TemplateJson.ts b/src/structured/TemplateJson.ts new file mode 100644 index 0000000..d2dd962 --- /dev/null +++ b/src/structured/TemplateJson.ts @@ -0,0 +1,165 @@ +import {int} from '../number'; +import {randomString} from '../string'; +import {clone} from '../util'; +import * as templates from './templates'; +import type { + ArrayTemplate, + BooleanTemplate, + FloatTemplate, + IntegerTemplate, + LiteralTemplate, + MapTemplate, + NumberTemplate, + ObjectTemplate, + OrTemplate, + StringTemplate, + Template, + TemplateNode, + TemplateShorthand, +} from './types'; + +export interface TemplateJsonOpts { + /** + * Sets the limit of maximum number of JSON nodes to generate. This is a soft + * limit: once this limit is reached, no further optional values are generate + * (optional object and map keys are not generated, arrays are generated with + * their minimum required size). + */ + maxNodes?: number; +} + +export class TemplateJson { + public static readonly gen = (template?: Template, opts?: TemplateJsonOpts): unknown => { + const generator = new TemplateJson(template, opts); + return generator.gen(); + }; + + protected nodes: number = 0; + protected maxNodes: number; + + constructor( + public readonly template: Template = templates.nil, + public readonly opts: TemplateJsonOpts = {}, + ) { + this.maxNodes = opts.maxNodes ?? 100; + } + + public gen(): unknown { + return this.generate(this.template); + } + + protected generate(tpl: Template): unknown { + this.nodes++; + while (typeof tpl === 'function') tpl = tpl(); + const template: TemplateNode = typeof tpl === 'string' ? [tpl] : tpl; + const type = template[0]; + switch (type) { + case 'arr': + return this.generateArray(template as ArrayTemplate); + case 'obj': + return this.generateObject(template as ObjectTemplate); + case 'map': + return this.generateMap(template as MapTemplate); + case 'str': + return this.generateString(template as StringTemplate); + case 'num': + return this.generateNumber(template as NumberTemplate); + case 'int': + return this.generateInteger(template as IntegerTemplate); + case 'float': + return this.generateFloat(template as FloatTemplate); + case 'bool': + return this.generateBoolean(template as BooleanTemplate); + case 'nil': + return null; + case 'lit': + return this.generateLiteral(template as any); + case 'or': + return this.generateOr(template as any); + default: + throw new Error(`Unknown template type: ${type}`); + } + } + + protected minmax(min: number, max: number): number { + if (this.nodes > this.maxNodes) return min; + if (this.nodes + max > this.maxNodes) max = this.maxNodes - this.nodes; + if (max < min) max = min; + return int(min, max); + } + + protected generateArray(template: ArrayTemplate): unknown[] { + const [, min = 0, max = 5, itemTemplate = 'nil', head = [], tail = []] = template; + const length = this.minmax(min, max); + const result: unknown[] = []; + for (const tpl of head) result.push(this.generate(tpl)); + for (let i = 0; i < length; i++) result.push(this.generate(itemTemplate)); + for (const tpl of tail) result.push(this.generate(tpl)); + return result; + } + + protected generateObject(template: ObjectTemplate): Record { + const [, fields = []] = template; + const result: Record = {}; + for (const field of fields) { + const [keyToken, valueTemplate = 'nil', optionality = 0] = field; + if (optionality) { + if (this.nodes > this.maxNodes) continue; + if (Math.random() < optionality) continue; + } + const key = randomString(keyToken ?? templates.tokensObjectKey); + const value = this.generate(valueTemplate); + result[key] = value; + } + return result; + } + + protected generateMap(template: MapTemplate): Record { + const [, keyToken, valueTemplate = 'nil', min = 0, max = 5] = template; + const length = this.minmax(min, max); + const result: Record = {}; + const token = keyToken ?? templates.tokensObjectKey; + for (let i = 0; i < length; i++) { + const key = randomString(token); + const value = this.generate(valueTemplate); + result[key] = value; + } + return result; + } + + protected generateString(template: StringTemplate): string { + return randomString(template[1] ?? templates.tokensHelloWorld); + } + + protected generateNumber([, min, max]: NumberTemplate): number { + if (Math.random() > 0.5) return this.generateInteger(['int', min, max]); + else return this.generateFloat(['float', min, max]); + } + + protected generateInteger(template: IntegerTemplate): number { + const [, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER] = template; + return int(min, max); + } + + protected generateFloat(template: FloatTemplate): number { + const [, min = -Number.MAX_VALUE, max = Number.MAX_VALUE] = template; + let float = Math.random() * (max - min) + min; + float = Math.max(min, Math.min(max, float)); + return float; + } + + protected generateBoolean(template: BooleanTemplate): boolean { + const value = template[1]; + return value !== undefined ? value : Math.random() < 0.5; + } + + protected generateLiteral(template: LiteralTemplate): unknown { + return clone(template[1]); + } + + protected generateOr(template: OrTemplate): unknown { + const [, ...options] = template; + const index = int(0, options.length - 1); + return this.generate(options[index]); + } +} diff --git a/src/structured/__tests__/TemplateJson.spec.ts b/src/structured/__tests__/TemplateJson.spec.ts new file mode 100644 index 0000000..5907f54 --- /dev/null +++ b/src/structured/__tests__/TemplateJson.spec.ts @@ -0,0 +1,513 @@ +import {resetMathRandom} from '../../__tests__/setup'; +import {deterministic} from '../../util'; +import {TemplateJson} from '../TemplateJson'; +import type {Template} from '../types'; + +describe('TemplateJson', () => { + describe('str', () => { + test('uses default string schema, if not provided', () => { + deterministic(123, () => { + expect(TemplateJson.gen(['str'])).toBe('Hi, Globe'); + expect(TemplateJson.gen('str')).toBe('Halo, World'); + expect(TemplateJson.gen('str')).toBe('Salutations, Earth!'); + }); + }); + + test('generates string according to schema', () => { + resetMathRandom(); + const str = TemplateJson.gen(['str', ['pick', 'foo', 'bar', 'baz']]); + expect(str).toBe('foo'); + }); + + test('handles complex string tokens', () => { + resetMathRandom(); + const str = TemplateJson.gen(['str', ['list', 'prefix-', ['pick', 'a', 'b'], '-suffix']]); + expect(str).toBe('prefix-a-suffix'); + }); + }); + + describe('int', () => { + test('uses default integer schema, if not provided', () => { + resetMathRandom(); + expect(TemplateJson.gen('int')).toBe(-8037967800187380); + resetMathRandom(123456); + expect(TemplateJson.gen(['int'])).toBe(4954609332676803); + }); + + test('can specify "int" range', () => { + resetMathRandom(); + expect(TemplateJson.gen(['int', -10, 10])).toBe(-9); + expect(TemplateJson.gen(['int', 0, 1])).toBe(0); + expect(TemplateJson.gen(['int', 1, 5])).toBe(4); + }); + + test('handles edge cases', () => { + resetMathRandom(); + expect(TemplateJson.gen(['int', 0, 0])).toBe(0); + expect(TemplateJson.gen(['int', -1, -1])).toBe(-1); + }); + }); + + describe('num', () => { + test('generates random number, without range', () => { + resetMathRandom(); + const num = TemplateJson.gen('num'); + expect(typeof num).toBe('number'); + }); + + test('can specify range', () => { + resetMathRandom(); + const num = TemplateJson.gen(['num', 0, 1]); + expect(num).toBeGreaterThanOrEqual(0); + expect(num).toBeLessThanOrEqual(1); + }); + + test('handles negative ranges', () => { + resetMathRandom(); + const num = TemplateJson.gen(['num', -10, -5]); + expect(num).toBeGreaterThanOrEqual(-10); + expect(num).toBeLessThanOrEqual(-5); + }); + }); + + describe('float', () => { + test('uses default float schema, if not provided', () => { + resetMathRandom(); + const float = TemplateJson.gen('float'); + expect(typeof float).toBe('number'); + }); + + test('can specify range', () => { + resetMathRandom(); + const float = TemplateJson.gen(['float', 0.1, 0.9]); + expect(float).toBeGreaterThanOrEqual(0.1); + expect(float).toBeLessThanOrEqual(0.9); + }); + + test('handles very small ranges', () => { + resetMathRandom(); + const float = TemplateJson.gen(['float', 1.0, 1.1]); + expect(float).toBeGreaterThanOrEqual(1.0); + expect(float).toBeLessThanOrEqual(1.1); + }); + }); + + describe('bool', () => { + test('uses default boolean schema, if not provided', () => { + resetMathRandom(); + const bool = TemplateJson.gen('bool'); + expect(typeof bool).toBe('boolean'); + }); + + test('can specify fixed value', () => { + expect(TemplateJson.gen(['bool', true])).toBe(true); + expect(TemplateJson.gen(['bool', false])).toBe(false); + }); + + test('generates random booleans when no value specified', () => { + resetMathRandom(); + expect(TemplateJson.gen(['bool'])).toBe(true); + resetMathRandom(999); + expect(TemplateJson.gen(['bool'])).toBe(true); + }); + }); + + describe('nil', () => { + test('always returns null', () => { + expect(TemplateJson.gen('nil')).toBe(null); + expect(TemplateJson.gen(['nil'])).toBe(null); + }); + }); + + describe('lit', () => { + test('returns literal values', () => { + expect(TemplateJson.gen(['lit', 42])).toBe(42); + expect(TemplateJson.gen(['lit', 'hello'])).toBe('hello'); + expect(TemplateJson.gen(['lit', true])).toBe(true); + expect(TemplateJson.gen(['lit', null])).toBe(null); + }); + + test('deep clones objects', () => { + const obj = {a: 1, b: {c: 2}}; + const result = TemplateJson.gen(['lit', obj]); + expect(result).toEqual(obj); + expect(result).not.toBe(obj); + expect((result as any).b).not.toBe(obj.b); + }); + + test('deep clones arrays', () => { + const arr = [1, [2, 3], {a: 4}]; + const result = TemplateJson.gen(['lit', arr]); + expect(result).toEqual(arr); + expect(result).not.toBe(arr); + expect((result as any)[1]).not.toBe(arr[1]); + expect((result as any)[2]).not.toBe(arr[2]); + }); + }); + + describe('arr', () => { + test('uses default array schema, if not provided', () => { + resetMathRandom(); + const arr = TemplateJson.gen('arr'); + expect(Array.isArray(arr)).toBe(true); + expect((arr as any[]).length).toBeGreaterThanOrEqual(0); + expect((arr as any[]).length).toBeLessThanOrEqual(5); + }); + + test('can specify length range', () => { + resetMathRandom(); + const arr = TemplateJson.gen(['arr', 2, 4]); + expect(Array.isArray(arr)).toBe(true); + expect((arr as any[]).length).toBeGreaterThanOrEqual(2); + expect((arr as any[]).length).toBeLessThanOrEqual(4); + }); + + test('can specify item template', () => { + resetMathRandom(); + const arr = TemplateJson.gen(['arr', 2, 2, 'str']); + expect(Array.isArray(arr)).toBe(true); + expect((arr as any[]).length).toBe(2); + expect(typeof (arr as any[])[0]).toBe('string'); + expect(typeof (arr as any[])[1]).toBe('string'); + }); + + test('can specify head templates', () => { + resetMathRandom(); + const arr = TemplateJson.gen([ + 'arr', + 1, + 1, + 'nil', + [ + ['lit', 'first'], + ['lit', 'second'], + ], + ]); + expect(Array.isArray(arr)).toBe(true); + expect((arr as any[])[0]).toBe('first'); + expect((arr as any[])[1]).toBe('second'); + }); + + test('can specify tail templates', () => { + resetMathRandom(); + const arr = TemplateJson.gen([ + 'arr', + 1, + 1, + 'nil', + [], + [ + ['lit', 'tail1'], + ['lit', 'tail2'], + ], + ]); + expect(Array.isArray(arr)).toBe(true); + const arrTyped = arr as any[]; + expect(arrTyped[arrTyped.length - 2]).toBe('tail1'); + expect(arrTyped[arrTyped.length - 1]).toBe('tail2'); + }); + + test('handles empty arrays', () => { + const arr = TemplateJson.gen(['arr', 0, 0]); + expect(Array.isArray(arr)).toBe(true); + expect((arr as any[]).length).toBe(0); + }); + }); + + describe('obj', () => { + test('uses default object schema, if not provided', () => { + const obj = TemplateJson.gen('obj'); + expect(typeof obj).toBe('object'); + expect(obj).not.toBe(null); + }); + + test('can specify fields', () => { + resetMathRandom(); + const obj = TemplateJson.gen([ + 'obj', + [ + ['name', 'str'], + ['age', 'int'], + ], + ]); + expect(typeof obj).toBe('object'); + expect(typeof (obj as any).name).toBe('string'); + expect(typeof (obj as any).age).toBe('number'); + }); + + test('handles optional fields', () => { + resetMathRandom(); + const obj = TemplateJson.gen([ + 'obj', + [ + ['required', 'str', 0], + ['optional', 'str', 1], + ], + ]); + expect(typeof (obj as any).required).toBe('string'); + expect((obj as any).optional).toBeUndefined(); + }); + + test('can use token for key generation', () => { + resetMathRandom(); + const obj = TemplateJson.gen(['obj', [[['pick', 'key1', 'key2'], 'str']]]); + expect(typeof obj).toBe('object'); + const keys = Object.keys(obj as any); + expect(keys.length).toBe(1); + expect(['key1', 'key2']).toContain(keys[0]); + }); + + test('handles null key token', () => { + resetMathRandom(); + const obj = TemplateJson.gen(['obj', [[null, 'str']]]); + expect(typeof obj).toBe('object'); + const keys = Object.keys(obj as any); + expect(keys.length).toBe(1); + }); + }); + + describe('map', () => { + test('uses default map schema when using shorthand', () => { + const map = TemplateJson.gen('map'); + expect(typeof map).toBe('object'); + expect(map).not.toBe(null); + expect(Array.isArray(map)).toBe(false); + }); + + test('generates map with default parameters', () => { + resetMathRandom(); + const map = TemplateJson.gen(['map', null]) as Record; + expect(typeof map).toBe('object'); + expect(map).not.toBe(null); + const keys = Object.keys(map); + expect(keys.length).toBeGreaterThanOrEqual(0); + expect(keys.length).toBeLessThanOrEqual(5); + }); + + test('generates map with custom key token', () => { + resetMathRandom(); + const map = TemplateJson.gen(['map', ['pick', 'key1', 'key2', 'key3'], 'str']) as Record; + expect(typeof map).toBe('object'); + const keys = Object.keys(map); + for (const key of keys) { + expect(['key1', 'key2', 'key3']).toContain(key); + expect(typeof map[key]).toBe('string'); + } + }); + + test('generates map with custom value template', () => { + resetMathRandom(); + const map = TemplateJson.gen(['map', null, 'int']) as Record; + expect(typeof map).toBe('object'); + const values = Object.values(map); + for (const value of values) { + expect(typeof value).toBe('number'); + expect(Number.isInteger(value)).toBe(true); + } + }); + + test('respects min and max constraints', () => { + resetMathRandom(); + const map1 = TemplateJson.gen(['map', null, 'str', 2, 2]) as Record; + expect(Object.keys(map1).length).toBe(2); + + resetMathRandom(); + const map2 = TemplateJson.gen(['map', null, 'str', 0, 1]) as Record; + const keys = Object.keys(map2); + expect(keys.length).toBeGreaterThanOrEqual(0); + expect(keys.length).toBeLessThanOrEqual(1); + }); + + test('handles complex nested templates', () => { + const map = deterministic(12345789, () => + TemplateJson.gen([ + 'map', + ['list', 'user_', ['pick', '1', '2', '3']], + [ + 'obj', + [ + ['name', 'str'], + ['age', 'int'], + ], + ], + ]), + ) as Record; + expect(typeof map).toBe('object'); + const keys = Object.keys(map); + for (const key of keys) { + expect(key).toMatch(/^user_[123]$/); + const value = map[key]; + expect(typeof value).toBe('object'); + expect(value).not.toBe(null); + expect(typeof (value as any).name).toBe('string'); + expect(typeof (value as any).age).toBe('number'); + } + }); + + test('handles empty map when min is 0', () => { + const map = TemplateJson.gen(['map', null, 'str', 0, 0]) as Record; + expect(typeof map).toBe('object'); + expect(Object.keys(map).length).toBe(0); + }); + + test('respects maxNodes limit', () => { + const map = TemplateJson.gen(['map', null, 'str', 10, 20], {maxNodes: 5}) as Record; + expect(typeof map).toBe('object'); + const keys = Object.keys(map); + expect(keys.length).toBeLessThanOrEqual(10); + }); + }); + + describe('or', () => { + test('picks one of the provided templates', () => { + resetMathRandom(); + const result = TemplateJson.gen(['or', 'str', 'int', 'bool']); + expect(['string', 'number', 'boolean']).toContain(typeof result); + }); + + test('works with complex templates', () => { + resetMathRandom(); + const result = TemplateJson.gen(['or', ['lit', 'hello'], ['lit', 42], ['lit', true]]); + expect(['hello', 42, true]).toContain(result); + }); + + test('handles single option', () => { + const result = TemplateJson.gen(['or', ['lit', 'only']]); + expect(result).toBe('only'); + }); + }); + + describe('maxNodeCount', () => { + test('respects node count limit', () => { + const result = TemplateJson.gen(['arr', 1, 100, 'str'], {maxNodes: 5}) as any[]; + expect(Array.isArray(result)).toBe(true); + expect(result.length > 2).toBe(true); + expect(result.length < 10).toBe(true); + }); + + test('works with nested structures', () => { + const template: any = ['arr', 3, 3, ['obj', [['key', 'str']]]]; + const result = TemplateJson.gen(template, {maxNodes: 10}); + expect(Array.isArray(result)).toBe(true); + }); + }); + + describe('edge cases', () => { + test('handles deeply nested structures', () => { + const template: any = [ + 'obj', + [ + [ + 'users', + [ + 'arr', + 2, + 2, + [ + 'obj', + [ + ['name', 'str'], + [ + 'profile', + [ + 'obj', + [ + ['age', 'int'], + ['active', 'bool'], + ], + ], + ], + ], + ], + ], + ], + ], + ]; + + resetMathRandom(); + const result = TemplateJson.gen(template); + expect(typeof result).toBe('object'); + expect(Array.isArray((result as any).users)).toBe(true); + expect((result as any).users.length).toBe(2); + }); + + test('handles recursive or templates', () => { + resetMathRandom(); + const result = TemplateJson.gen(['or', ['or', 'str', 'int'], 'bool']); + expect(['string', 'number', 'boolean']).toContain(typeof result); + }); + + test('handles empty object fields', () => { + const result = TemplateJson.gen(['obj', []]); + expect(typeof result).toBe('object'); + expect(Object.keys(result as any).length).toBe(0); + }); + + test('handles very large integer ranges', () => { + resetMathRandom(); + const result = TemplateJson.gen(['int', Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]); + expect(typeof result).toBe('number'); + expect(Number.isInteger(result)).toBe(true); + }); + }); +}); + +describe('recursive templates', () => { + test('handles recursive structures', () => { + const user = (): Template => [ + 'obj', + [ + ['id', ['str', ['repeat', 4, 8, ['pick', ...'0123456789'.split('')]]]], + ['friend', user, 0.2], + ], + ]; + const result = deterministic(123, () => TemplateJson.gen(user)); + expect(result).toEqual({ + id: '4960', + friend: { + id: '93409', + friend: { + id: '898338', + friend: { + id: '638225', + friend: { + id: '1093', + friend: { + id: '7985', + friend: { + id: '7950', + friend: { + id: '593382', + friend: { + id: '9670919', + }, + }, + }, + }, + }, + }, + }, + }, + }); + }); + + test('can limit number of nodes', () => { + const user = (): Template => [ + 'obj', + [ + ['id', ['str', ['repeat', 4, 8, ['pick', ...'0123456789'.split('')]]]], + ['friend', user, 0.2], + ], + ]; + const result = deterministic(123, () => TemplateJson.gen(user, {maxNodes: 5})); + expect(result).toEqual({ + id: '4960', + friend: { + id: '93409', + friend: { + id: '898338', + }, + }, + }); + }); +}); diff --git a/src/structured/index.ts b/src/structured/index.ts new file mode 100644 index 0000000..b97d87f --- /dev/null +++ b/src/structured/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export {TemplateJson, TemplateJsonOpts} from './TemplateJson'; diff --git a/src/structured/templates.ts b/src/structured/templates.ts new file mode 100644 index 0000000..76230ca --- /dev/null +++ b/src/structured/templates.ts @@ -0,0 +1,21 @@ +import type {Token} from '../string'; +import type {StringTemplate, Template} from './types'; + +export const nil: Template = 'nil'; + +export const tokensHelloWorld: Token = [ + 'list', + ['pick', 'hello', 'Hello', 'Halo', 'Hi', 'Hey', 'Greetings', 'Salutations'], + ['pick', '', ','], + ' ', + ['pick', 'world', 'World', 'Earth', 'Globe', 'Planet'], + ['pick', '', '!'], +]; + +export const tokensObjectKey: Token = [ + 'pick', + ['pick', 'id', 'name', 'type', 'tags', '_id', '.git', '__proto__', ''], + ['list', ['pick', 'user', 'group', '__system__'], ['pick', '.', ':', '_', '$'], ['pick', 'id', '$namespace', '$']], +]; + +export const str: StringTemplate = ['str', tokensHelloWorld]; diff --git a/src/structured/types.ts b/src/structured/types.ts new file mode 100644 index 0000000..fdfe959 --- /dev/null +++ b/src/structured/types.ts @@ -0,0 +1,191 @@ +import type {Token} from '../string'; + +/** + * Schema (template) for random JSON generation. + */ +export type Template = TemplateShorthand | TemplateNode | TemplateRecursiveReference; + +export type TemplateNode = + | LiteralTemplate + | NumberTemplate + | IntegerTemplate + | FloatTemplate + | StringTemplate + | BooleanTemplate + | NullTemplate + | ArrayTemplate + | ObjectTemplate + | MapTemplate + | OrTemplate; + +export type TemplateShorthand = 'num' | 'int' | 'float' | 'str' | 'bool' | 'nil' | 'arr' | 'obj' | 'map'; + +/** + * Recursive reference allows for recursive template construction, for example: + * + * ```ts + * const user = (): Template => ['obj', [ + * ['id', ['str', ['repeat', 4, 8, ['pick', ...'0123456789'.split('')]]]], + * ['friend', user, .2] // <--- Probability 20% + * ]]; + * ``` + * + * The above corresponds to: + * + * ```ts + * interface User { + * id: string; + * friend?: User; // <--- Recursive + * } + * ``` + */ +export type TemplateRecursiveReference = () => Template; + +/** + * Literal value template. The literal value is deeply cloned when generating + * the random JSON and inserted as-is. + */ +export type LiteralTemplate = ['lit', value: unknown]; + +/** + * Number template. Generates a random number within the specified range. Can be + * a floating-point number or an integer. + */ +export type NumberTemplate = [type: 'num', min?: number, max?: number]; + +/** + * Integer template. Generates a random integer within the specified range. + * If no range is specified, it defaults to the full range of JavaScript integers. + */ +export type IntegerTemplate = [type: 'int', min?: number, max?: number]; + +/** + * Float template. Generates a random floating-point number within the specified + * range. If no range is specified, it defaults to the full range of JavaScript + * floating-point numbers. + */ +export type FloatTemplate = [type: 'float', min?: number, max?: number]; + +/** + * String template. Generates a random string based on the + * provided {@link Token} schema. If no token is specified, it defaults to a + * simple string generation. + */ +export type StringTemplate = [type: 'str', token?: Token]; + +/** + * Boolean template. Generates a random boolean value. If a specific value is + * provided, it will always return that value; otherwise, it randomly returns + * `true` or `false`. + */ +export type BooleanTemplate = [type: 'bool', value?: boolean]; + +/** + * Null template. Generates a `null` value. If a specific value is provided, it + * will always return that value; otherwise, it returns `null`. + */ +export type NullTemplate = [type: 'nil']; + +/** + * Array template. Generates a random array. If no template is specified, it + * uses the default template. If a template is provided, it generates an array + * of random values based on that template. + */ +export type ArrayTemplate = [ + type: 'arr', + /** + * The minimum number of elements in the array. + */ + min?: number, + /** + * The maximum number of elements in the array. + */ + max?: number, + /** + * The template to use for generating the array elements. + */ + template?: Template, + /** + * The templates to use for generating the *head* array elements. The head + * is the "tuple" part of the array that is generated before the main template. + */ + head?: Template[], + /** + * The templates to use for generating the *tail* array elements. The tail + * is the "rest" part of the array that is generated after the main template. + */ + tail?: Template[], +]; + +/** + * Object template. Generates a random object. If no fields are specified, it + * uses the default template. If fields are provided, it generates an object + * with those fields, where each field can be optional or required. + */ +export type ObjectTemplate = [ + type: 'obj', + /** + * Fields of the object. Once can specify key and value templates for each + * field. The key can be a string or a token, and the value can be any + * valid JSON template. Fields can also be optional. Fields are generated + * in a random order. + */ + fields?: ObjectTemplateField[], +]; + +/** + * Specifies a field in an object template. + */ +export type ObjectTemplateField = [ + /** + * The key of the field. Can be a string or a {@link Token} to generate a + * random key. If `null`, the default key {@link Token} will be used. + */ + key: Token | null, + /** + * The template for the value of the field. If not specified, the default + * template will be used. + */ + value?: Template, + /** + * Whether the field is optional. This number specifies a probability from 0 + * to 1 that the field will be included in the generated object. A value of + * 0 means the field is required, and a value of 1 means the field is omitted + * with a probability of 1. If not specified, the field is required (0 + * probability of omission). + */ + optionality?: number, +]; + +/** + * Generates a random map-like (record) structure, where every value has the + * same template. + */ +export type MapTemplate = [ + type: 'map', + /** + * Token to use for generating the keys of the map. If `null` or not set, + * the default key {@link Token} will be used. + */ + key?: Token | null, + /** + * The template for the value of the map. If not specified, the default + * template will be used. + */ + value?: Template, + /** + * The minimum number of entries in the map. Defaults to 0. + */ + min?: number, + /** + * The maximum number of entries in the map. Defaults to 5. + */ + max?: number, +]; + +/** + * Union type for templates that can be used in a random JSON generation. + * This allows for flexible combinations of different template types. The "or" + * operator picks one of the provided templates at random. + */ +export type OrTemplate = ['or', ...Template[]]; diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..769497c --- /dev/null +++ b/src/util.ts @@ -0,0 +1,66 @@ +import {isUint8Array} from '@jsonjoy.com/buffers/lib/isUint8Array'; + +const random = Math.random; + +export const rnd = + (seed = 123456789) => + () => { + seed = (seed * 48271) % 2147483647; + return (seed - 1) / 2147483646; + }; + +/** + * Executes code in a callback *deterministically*: the `Math.random()` function + * is mocked for the duration of the callback. + * + * Example: + * + * ```js + * deterministic(123, () => { + * return Math.random() + 1; + * }); + * ``` + * + * @param rndSeed A seed number or a random number generator function. + * @param code Code to execute deterministically. + * @returns Return value of the code block. + */ +export const deterministic = (rndSeed: number | (() => number), code: () => T): T => { + const isNative = Math.random === random; + Math.random = typeof rndSeed === 'function' ? rndSeed : rnd(Math.round(rndSeed)); + try { + return code(); + } finally { + if (isNative) Math.random = random; + } +}; + +const {isArray} = Array; +const objectKeys = Object.keys; + +/** + * Creates a deep clone of any JSON-like object. + * + * @param obj Any plain POJO object. + * @returns A deep copy of the object. + */ +export const clone = (obj: T): T => { + if (!obj) return obj; + if (isArray(obj)) { + const arr: unknown[] = []; + const length = obj.length; + for (let i = 0; i < length; i++) arr.push(clone(obj[i])); + return arr as unknown as T; + } else if (typeof obj === 'object') { + if (isUint8Array(obj)) return new Uint8Array(obj) as unknown as T; + const keys = objectKeys(obj!); + const length = keys.length; + const newObject: any = {}; + for (let i = 0; i < length; i++) { + const key = keys[i]; + newObject[key] = clone((obj as any)[key]); + } + return newObject; + } + return obj; +}; diff --git a/yarn.lock b/yarn.lock index 7f1d9ef..97fed29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -593,6 +593,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jsonjoy.com/buffers@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/buffers/-/buffers-1.0.0.tgz#ade6895b7d3883d70f87b5743efaa12c71dfef7a" + integrity sha512-NDigYR3PHqCnQLXYyoLbnEdzMMvzeiCWo1KOut7Q0CoIqg9tUAPKJ1iq/2nFhc5kZtexzutNY0LFjdwWL3Dw3Q== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"