diff --git a/pkg/spec/src/index.ts b/pkg/spec/src/index.ts index 370e900..809f4bf 100644 --- a/pkg/spec/src/index.ts +++ b/pkg/spec/src/index.ts @@ -52,3 +52,4 @@ export { weighted, whenRoute, } from "./actions.ts"; +export { edgeCaseText, emails, integers, strings } from "./values.ts"; diff --git a/pkg/spec/src/values.ts b/pkg/spec/src/values.ts new file mode 100644 index 0000000..818c386 --- /dev/null +++ b/pkg/spec/src/values.ts @@ -0,0 +1,104 @@ +// Author-facing, fluent, seeded value generators. Each builder is itself a +// Sampler (it has .generate()), so `integers().between(0, 9)` is usable +// anywhere a Sampler is expected and chains read left to right. +// +// Every draw goes through the shared samplerRng (sampler-rng.ts) that from() +// uses, so generators are deterministic AND identical across the goja verifier +// and the V8 web runtime. Outside an actions() walk samplerRng is null and each +// generator returns a fixed deterministic default (mirroring from()'s index 0), +// so module-load-time calls never throw and never use an unseeded source. + +import type { Pcg } from "./pcg.ts"; +import type { Sampler } from "./types.ts"; +import { getSamplerRng } from "./sampler-rng.ts"; +import { INPUT_CORPUS } from "./corpus.ts"; + +const ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const DIGITS = "0123456789"; +const ALPHANUMERIC = ALPHA + DIGITS; + +function draw(rng: Pcg | null, bound: number): number { + return rng && bound > 1 ? rng.intN(bound) : 0; +} + +class StringBuilder implements Sampler { + private minLength = 1; + private maxLength = 16; + private charset = ALPHANUMERIC; + + length(min: number, max: number): this { + this.minLength = min; + this.maxLength = max; + return this; + } + + alpha(): this { + this.charset = ALPHA; + return this; + } + + generate(): string { + const rng = getSamplerRng(); + const span = this.maxLength - this.minLength + 1; + const count = this.minLength + draw(rng, span); + let result = ""; + for (let i = 0; i < count; i++) { + result += this.charset[draw(rng, this.charset.length)]; + } + return result; + } +} + +class IntegerBuilder implements Sampler { + private minValue = 0; + private maxValue = 2 ** 31 - 1; + + between(min: number, max: number): this { + this.minValue = min; + this.maxValue = max; + return this; + } + + generate(): number { + const rng = getSamplerRng(); + const span = this.maxValue - this.minValue + 1; + return this.minValue + draw(rng, span); + } +} + +class EmailBuilder implements Sampler { + private host = "example.com"; + + domain(domain: string): this { + this.host = domain; + return this; + } + + generate(): string { + const local = new StringBuilder().length(3, 8).alpha().generate(); + return `${local}@${this.host}`; + } +} + +class EdgeCaseTextBuilder implements Sampler { + generate(): string { + const rng = getSamplerRng(); + return INPUT_CORPUS[draw(rng, INPUT_CORPUS.length)] ?? ""; + } +} + +export function strings(): StringBuilder { + return new StringBuilder(); +} + +export function integers(): IntegerBuilder { + return new IntegerBuilder(); +} + +export function emails(): EmailBuilder { + return new EmailBuilder(); +} + +export function edgeCaseText(): EdgeCaseTextBuilder { + return new EdgeCaseTextBuilder(); +}