From cdf9c964888e29a3ac3deb1331829e0f124e896c Mon Sep 17 00:00:00 2001 From: Gabriel Lopes Veiga Date: Mon, 10 May 2021 23:25:50 -0300 Subject: [PATCH] remove runtime stuff & add generators no more decorators --- README.md | 80 ++++++---------- package.json | 2 +- src/__mocks__/config.ts | 5 - src/cache.ts | 32 +++++++ src/classes.ts | 50 +++++++--- src/config.ts | 42 --------- src/decorators.ts | 65 ------------- src/generators.ts | 112 +++++++++++++++++++++++ src/index.ts | 3 +- src/instances.ts | 140 ++++------------------------ src/private.ts | 5 - src/utils.ts | 4 + src/validators.ts | 125 +++++++++++++------------ test/config.test.ts | 25 ----- test/decorators.test.ts | 66 -------------- test/generators.test.ts | 111 ++++++++++++++++++++++ test/instances.test.ts | 198 +++++----------------------------------- test/readme.test.js | 41 ++++----- test/tsconfig.json | 6 +- test/validators.test.ts | 136 +++++---------------------- tsconfig.json | 4 +- 21 files changed, 477 insertions(+), 775 deletions(-) delete mode 100644 src/__mocks__/config.ts create mode 100644 src/cache.ts delete mode 100644 src/config.ts delete mode 100644 src/decorators.ts create mode 100644 src/generators.ts delete mode 100644 src/private.ts delete mode 100644 test/config.test.ts delete mode 100644 test/decorators.test.ts create mode 100644 test/generators.test.ts diff --git a/README.md b/README.md index adfb761..e17db1a 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,9 @@ const addable = new Class({ Instances are JavaScript classes that behave according to some (type) class. -Using the Addable example above, one could almost define an instance as: +Let's start with the following class: ```javascript -// you may use this decorator as many times as needed -@instance(addable) class Number { constructor(n) { this.n = n @@ -109,70 +107,52 @@ class Number { } ``` -The only extra step is to define a static method called `generateData`, that -will take any number of random numbers in the range [0, 1] as parameters -and should return a random instance value. +In order to declare it as an instance of something, you must provide a way of +generating values from your constructor. These are the values that will be used +for testing. (See [How it works](#lt-how-it-works)) + +There are two ways of doing that: ```javascript -@instance(addable) -class Number { - // ... +// you may ask for as many parameters as you want, and to each one will be +// assigned a random number between 0 and 1 (inclusive) +// from these numbers you may generate an instance of your constructor +const gen = continuous((n) => new Number(n)) - static generateData(n) { - // this is quite a trivial example, we just wrap the n. - // in case you need more random values, just add them as parameters and they - // will be provided - return new Number(n) - } -} +// note that, to increase the likelihood of catching edge cases, sometimes the +// generated numbers will be all 0s or 1s +``` + +```javascript +// testing values will be sampled from the given array +const gen = discrete([new Number(0), new Number(1), new Number(2)]) + +// this method would be more useful if we had a finite number of possible +// values, which is not the case ``` -With that done, you need only to call `validate` on the constructor and the -validators will run. **You should call this at some point in your tests.** +And then you only need to call `instance` with the correct parameters and +the validators will run. **You should call this at some point in your tests.** ```javascript // will throw an Error if it fails -validate(Number) +instance(Number, addable, gen) ``` -## Checking - -You may also check whether a value is of an instance of a class using the -`isInstance` function: +Additionally, you may specify how many times each law will be tested (The +default is 15 times): ```javascript -const n = new Number(50) -isInstance(n, addable) // true, because Numbers are addable +instance(Number, addable, gen, { sampleSize: 10 }) ``` -## How it works - -When you define your constructor using the `@instance` decorator, some metadata -will be injected into it and into every value it produces. That metadata will -be used to both run the proper validations during `validate` and also to allow -the `isInstance` to work. +

How it works

-When `validate` is called, for each class that the constructor should be an -instance of, a sample of random instance values will be generated using your -constructor's `generateData`, and each class property will be tested using those. +When `instance` is called, a sample of random instance values will be created +using your provided generator, and each class property will be tested using +those. If any of the laws fails to be asserted, an error is thrown, and you may be sure that the constructor in question is not an instance of the class you declared in the decorator. In case it passes, you may have a high confidence that it is. - -## What if I can't use decorators? - -An approach would be to use the `instance(...)` decorator as a regular -function: - -```javascript -class Number { - // ... -} - -// new instances shall be instantiated using the returned constructor -const VNumber = instance(addable)(Number) - -validate(VNumber) -``` diff --git a/package.json b/package.json index 82b0464..6cacf8d 100644 --- a/package.json +++ b/package.json @@ -47,4 +47,4 @@ "jest --bail --findRelatedTests" ] } -} +} \ No newline at end of file diff --git a/src/__mocks__/config.ts b/src/__mocks__/config.ts deleted file mode 100644 index 2dfaf69..0000000 --- a/src/__mocks__/config.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const config = { - generateRandom: Math.random, - skipValidations: false, - testSampleSize: 15, -} diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..fa0404d --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,32 @@ +import { Class } from './classes' +import { Constructor } from './utils' + +class Cache { + public readonly map: Map + + constructor() { + this.map = new Map() + } + + set(Constructor: Constructor, clazz: Class): Class[] { + const existing = this.get(Constructor) + + const newClasses = existing ? [...existing, clazz] : [clazz] + + this.map.set(Constructor, newClasses) + + return newClasses + } + + get(Constructor: Constructor): Class[] | undefined { + return this.map.get(Constructor) + } + + contains(Constructor: Constructor, clazz: Class) { + const existing = this.get(Constructor) + + return existing && existing.some((clazz2) => clazz2.equals(clazz)) + } +} + +export const cache = new Cache() diff --git a/src/classes.ts b/src/classes.ts index b43004b..aac4ea9 100644 --- a/src/classes.ts +++ b/src/classes.ts @@ -1,8 +1,14 @@ -import { InstanceConstructor } from './instances' -import { MaybeError } from './utils' -import { all, ValidationResult, Validator } from './validators' +import { cache } from './cache' +import { ConstructorValuesGenerator } from './generators' +import { Constructor, MaybeError } from './utils' +import { + all, + ValidationResult, + InstanceValidator, + ValidationOptions, +} from './validators' -type Laws = Validator +type Laws = InstanceValidator export interface ClassOptions { /** The name will be used to improve error messages. */ @@ -51,19 +57,35 @@ export class Class { } /** - * Checks if something is an instance of this class, not taking parents into - * account. + * Checks if something is an instance of this class. * - * This is probably not what you're looking for: If you want to properly check - * if something is an instance of a class, check out the `validate` procedure. - * - * @param instance + * @param Constructor * @returns - * - * @see {@link validate} */ - validate(instance: InstanceConstructor): ValidationResult { - return this.laws.check(instance) + validate( + Constructor: T, + values: ConstructorValuesGenerator, + options: ValidationOptions = {}, + ): ValidationResult { + // check cache + if (cache.contains(Constructor, this)) { + return MaybeError.success() + } + + // not cached + const result = MaybeError.foldConjoin([ + // parents + ...this.parents.map((parent) => + parent.validate(Constructor, values, options), + ), + // constructor itself + this.laws.check(Constructor, values, options), + ]) + + // cache + cache.set(Constructor, this) + + return result } equals(other: Class) { diff --git a/src/config.ts b/src/config.ts deleted file mode 100644 index 6a1e567..0000000 --- a/src/config.ts +++ /dev/null @@ -1,42 +0,0 @@ -const testSampleSize = Symbol('Test sample size') -const skipValidations = Symbol('Skip validations') -const generateRandom = Symbol('Generate random') - -export const config = { - [generateRandom]: Math.random, - [skipValidations]: false, - [testSampleSize]: 15, - - /** A callable that shall return a random number. */ - get generateRandom() { - return this[generateRandom] - }, - - set generateRandom(value: () => number) { - this[generateRandom] = value - }, - - /** If true, class validations will not be performed. */ - get skipValidations() { - return this[skipValidations] - }, - - set skipValidations(value: boolean) { - this[skipValidations] = !!value - }, - - /** The number of times each instance will be validated against its supposed class. - * Note that, because of the edge cases 0 and 1, that are always tested against, this - * effectively has a minimum value of 2. */ - get testSampleSize() { - return this[testSampleSize] - }, - - set testSampleSize(value: number) { - if (value < 0) { - throw new Error(`Test sample size cannot be negative (got ${value}).`) - } - - this[testSampleSize] = Math.trunc(value) - }, -} diff --git a/src/decorators.ts b/src/decorators.ts deleted file mode 100644 index 6065511..0000000 --- a/src/decorators.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Class } from './classes' -import { - InstanceConstructor, - InstanceMetadata, - KnownInstance, - KnownInstanceConstructor, -} from './instances' -import { metadataKey } from './private' - -/** - * Declares a constructor to be an instance of the given class. - * The validation is not done in the spot, you must use the `validate` procedure - * to actually do it. That would usually happen as part of your application's - * tests. - * - * @param theClass - The class that this is an instance of. - * @returns A function that checks whether the given constructor is an instance of theClass - * or not. - * - * @example - * ```javascript - * `@instance(semigroup)` // (this should not be a string) - * class Addition { - * constructor(x) { this.x = x } - * add(y) { return new Addition(this.x + y.x) } - * } - * ``` - * - * @example - * ``` - * // if you cannot use decorators, just call it as a function - * instance(semigroup)(Addition) - * ``` - * - * @see {@link validate} - */ -export function instance( - theClass: Class, -): (Constructor: T) => T & KnownInstanceConstructor { - return function (Constructor: T): T & KnownInstanceConstructor { - const existingMetadata = (Constructor as any)[metadataKey] - - const newMetadata = existingMetadata - ? { validated: false, classes: [...existingMetadata.classes, theClass] } - : { validated: false, classes: [theClass] } - - class NewClass extends Constructor implements KnownInstance { - public [metadataKey]: InstanceMetadata - public static [metadataKey]: InstanceMetadata - - constructor(...args: any[]) { - super(...args) - - // insert the metadata into the values so isInstance may see it - this[metadataKey] = newMetadata! - } - } - - // insert the metadata into the constructor so we may easily see it - // in later invocations and append things if necessary - NewClass[metadataKey] = newMetadata! - - return NewClass - } -} diff --git a/src/generators.ts b/src/generators.ts new file mode 100644 index 0000000..d7cf5fe --- /dev/null +++ b/src/generators.ts @@ -0,0 +1,112 @@ +import { arrayWithLength, Constructor } from './utils' + +export interface ConstructorValuesGenerator { + get(i: number): InstanceType +} + +export type RandomFunction = () => number + +/** + * Generates "random" numbers in the range [0, 1[. + * + * @returns + */ +export function defaultRandom(): number { + return Math.random() +} + +export class Continuous + implements ConstructorValuesGenerator { + public readonly random: RandomFunction + + /** + * + * @param f A function from _n_ real numbers in the range [0, 1] to a value of + * T + * @param random A function that generates pseudo-random numbers. For each + * value that is instantiated, this function will be called with a different + * parameter `x`, and every random parameter will be a different `y`. + * + * The default implementation will always generate 0s for the first value, 1s + * for the second one, and the rest will be random. + */ + constructor( + public readonly f: (...n: number[]) => InstanceType, + random?: RandomFunction, + ) { + this.random = random || defaultRandom + } + + public get(i: number): InstanceType { + const amountOfParams = this.f.length + const params = arrayWithLength(amountOfParams).map(() => { + if (i === 0) return 0 + if (i === 1) return 1 + + return this.random() + }) + + return this.f(...params) + } +} + +export class Discrete + implements ConstructorValuesGenerator { + public readonly random: RandomFunction + + /** + * + * @param values A list of discrete values the constructor may assume + */ + constructor( + public readonly values: InstanceType[], + random?: RandomFunction, + ) { + this.random = random || defaultRandom + } + + public get(i: number): InstanceType { + const randomIndex = Math.floor(this.random() * this.values.length) + + return this.values[randomIndex] + } +} + +/** + * Generate testing values via a function. + * + * @example + * ``` + * // a will be a random number, so the Foo value should be too + * instance(Foo, bar, continuous((a) => new Foo(a))) + * ``` + * + * @param f + * @param random + * @returns + */ +export function continuous( + f: (...n: number[]) => InstanceType, + random?: RandomFunction, +) { + return new Continuous(f, random) +} + +/** + * Generate testing values from a finite list + * + * @example + * ``` + * // random values will be sampled from that list + * instance(Foo, bar, discrete([new Foo(1), new Foo(2), new Foo(3)])) + * ``` + * + * @param values + * @returns + */ +export function discrete( + values: InstanceType[], + random?: RandomFunction, +) { + return new Discrete(values, random) +} diff --git a/src/index.ts b/src/index.ts index b772c16..e4299d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ export * from './classes' -export * from './decorators' +export * from './generators' export * from './instances' export * from './validators' -export * from './config' diff --git a/src/instances.ts b/src/instances.ts index da935f0..a90ed22 100644 --- a/src/instances.ts +++ b/src/instances.ts @@ -1,86 +1,15 @@ import { Class } from './classes' -import { metadataKey, Metadatable } from './private' -import { MaybeError } from './utils' - -export interface InstanceMetadata { - classes: Class[] -} +import { ConstructorValuesGenerator } from './generators' +import { Constructor, MaybeError } from './utils' +import { ValidationOptions } from './validators' /** - * An InstanceConstructor must implement a `generateData` method, that shall - * generate random instance values based on any amount of random numbers. + * Validates if a constructor is an instance of a given class. * - * Those random instances will be used to check against the class laws. - */ -export interface InstanceConstructor extends Function { - new (...args: any[]): any - generateData(...xs: number[]): InstanceType -} - -export interface KnownInstanceConstructor - extends InstanceConstructor, - Metadatable { - new (...args: any[]): KnownInstance -} - -export interface KnownInstance extends Metadatable {} - -/** - * Given a value, return if that is a value of an instance of the given class. - * - * @param value - * @param theClass - * @returns - * - * @example - * ```javascript - * const show = new Class({ - * // ... - * }); - * - * `@instance(show)` // (this should not be a string) - * class Showable implements Show { - * // ... - * } - * - * const showable = new Showable(); - * - * // true - * isInstance(showable, show); - * ``` - */ -export function isInstance(value: any, theClass: Class): boolean { - const metadata = value[metadataKey] as InstanceMetadata | null - - if (!metadata) { - return false - } - - const classes = metadata.classes - - return classes.some((candidateClass) => { - // true if the candidate class is the class we're looking for or if one of - // it's parents is - return ( - candidateClass.equals(theClass) || - candidateClass.parents.some((parent) => parent.equals(theClass)) - ) - }) -} - -/** - * Effectively validates if something is an instance of what it claims to be. - * - * This procedure will traverse through every class the instance should conform - * to and check if the tests pass. - * - * This was made to be executed during your tests, not during runtime, although - * there is nothing keeping you from doing it. - * - * @param MaybeConstructor + * @param Constructor + * @param clazz * * @throws If the validation fails. - * @throws If the given value isn't an instance of anything. * * @example * ```javascript @@ -88,60 +17,25 @@ export function isInstance(value: any, theClass: Class): boolean { * // ... * }); * - * `@instance(show)` // (this should not be a string) * class Showable implements Show { * // ... * } * * // will throw if it fails - * validate(Showable); + * instance(Showable, show); * ``` */ -export function validate(MaybeConstructor: Function) { - const metadata = (MaybeConstructor as KnownInstanceConstructor)[ - metadataKey - ] as InstanceMetadata | null - - if (!metadata) { - throw new Error( - MaybeError.fail( - `${MaybeConstructor.name} is not an instance of anything.`, - ).value!, - ) - } - - const Constructor = MaybeConstructor as KnownInstanceConstructor - - const validatedClassIds: number[] = [] - const results: MaybeError[] = [] - - // recursively validate every class only once - const work = (clazz: Class): void => { - // if the class has been seen, do nothing - if (validatedClassIds.includes(clazz.id)) { - return - } - - results.push(clazz.validate(Constructor)) - validatedClassIds.push(clazz.id) - - // repeat for every parent - for (const parent of clazz.parents) { - work(parent) - } - } - - for (const clazz of metadata.classes) { - work(clazz) - } - - const maybeError = MaybeError.foldConjoin(results) - - if (maybeError.isError()) { +export function instance( + Constructor: T, + clazz: Class, + values: ConstructorValuesGenerator, + options: ValidationOptions = {}, +) { + const result = clazz.validate(Constructor, values, options) + + if (result.isError()) { throw new Error( - maybeError.conjoin( - MaybeError.fail(`${Constructor.name} is invalid.`), - ).value!, + result.conjoin(MaybeError.fail(`${Constructor.name} is invalid.`)).value!, ) } } diff --git a/src/private.ts b/src/private.ts deleted file mode 100644 index 5666ed2..0000000 --- a/src/private.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const metadataKey = Symbol('Lawful Typeclass Metadata') - -export interface Metadatable { - [metadataKey]: T -} diff --git a/src/utils.ts b/src/utils.ts index ebbd5e9..cfb4fdd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,7 @@ +export interface Constructor { + new (...args: any[]): any +} + export function arrayWithLength(n: number): Array { return new Array(n).fill(0) } diff --git a/src/validators.ts b/src/validators.ts index 5c12f95..94ae1fe 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -1,49 +1,37 @@ -import { config } from './config' -import { InstanceConstructor } from './instances' -import { arrayWithLength, MaybeError } from './utils' +import { ConstructorValuesGenerator } from './generators' +import { arrayWithLength, Constructor, MaybeError } from './utils' + +export interface ValidationOptions { + sampleSize?: number +} export type ValidationResult = MaybeError -export interface Validator { - check(instance: T): ValidationResult +export interface InstanceValidator { + check( + Instance: T, + values: ConstructorValuesGenerator, + options?: ValidationOptions, + ): ValidationResult } -type Predicate = ( +export type Predicate = ( Instance: T, ...data: InstanceType[] ) => boolean -type InstanceValidator = Validator - -const specialCases = [0, 1] - -const getRandomSampleSize = (): number => { - const { testSampleSize } = config - - if (testSampleSize < specialCases.length) { - throw new Error( - `Test sample size cannot be ${testSampleSize}, there are ${specialCases.length} special cases that must be tested. that is the minimum acceptable value.`, - ) - } - - return testSampleSize - specialCases.length -} - // An Obey of T validates using instances of T -export class Obeys implements InstanceValidator { +export class Obeys implements InstanceValidator { constructor(public readonly param: Predicate) {} - check(Instance: T): ValidationResult { - const { skipValidations, generateRandom } = config - - if (skipValidations) { - return MaybeError.success() - } + check( + Instance: T, + values: ConstructorValuesGenerator, + options: ValidationOptions = {}, + ): ValidationResult { + const { sampleSize = 15 } = options const predicate = this.param - - const paramsForInstance = Instance.generateData.length - // the first parameter is the instance itself const paramsForPredicate = predicate.length - 1 const fail = (params: any[]): ValidationResult => { @@ -54,30 +42,29 @@ export class Obeys implements InstanceValidator { ) } - for (var specialCase of specialCases) { - const params = arrayWithLength(paramsForPredicate).map(() => { - return Instance.generateData( - ...new Array(paramsForInstance).fill(specialCase), - ) - }) + for (var i = 0; i < sampleSize; i++) { + const params = [] - if (!predicate(Instance, ...params)) { - return fail(params) - } - } + for (var j = 0; j < paramsForPredicate; j++) { + try { + // may throw + const param = values.get(i) - const randomSampleSize = getRandomSampleSize() + params.push(param) + } catch (error) { + return MaybeError.fail(error.message).conjoin(fail(params)) + } + } - for (var i = 0; i < randomSampleSize; i++) { - const params = arrayWithLength(paramsForPredicate).map(() => { - return Instance.generateData( - // impure - ...arrayWithLength(paramsForInstance).map(generateRandom), - ) - }) + try { + // may throw + const result = predicate(Instance, ...params) - if (!predicate(Instance, ...params)) { - return fail(params) + if (!result) { + return fail(params) + } + } catch (error) { + return MaybeError.fail(error.message).conjoin(fail(params)) } } @@ -85,12 +72,16 @@ export class Obeys implements InstanceValidator { } } -export class All implements InstanceValidator { - constructor(public readonly param: InstanceValidator[]) {} +export class All implements InstanceValidator { + constructor(public readonly param: InstanceValidator[]) {} - check(instance: InstanceConstructor): ValidationResult { + check( + Instance: T, + values: ConstructorValuesGenerator, + options: ValidationOptions = {}, + ): ValidationResult { const result = MaybeError.foldConjoin( - this.param.map((val) => val.check(instance)), + this.param.map((val) => val.check(Instance, values, options)), ) return result.isError() @@ -99,12 +90,16 @@ export class All implements InstanceValidator { } } -export class Any implements InstanceValidator { - constructor(public readonly param: InstanceValidator[]) {} +export class Any implements InstanceValidator { + constructor(public readonly param: InstanceValidator[]) {} - check(instance: InstanceConstructor): ValidationResult { + check( + Instance: T, + values: ConstructorValuesGenerator, + options: ValidationOptions = {}, + ): ValidationResult { const result = MaybeError.foldDisjoin( - this.param.map((val) => val.check(instance)), + this.param.map((val) => val.check(Instance, values, options)), ) return result.isError() @@ -126,7 +121,7 @@ export class Any implements InstanceValidator { * }) * ``` */ -export function obey(predicate: Predicate) { +export function obey(predicate: Predicate) { return new Obeys(predicate) } @@ -142,7 +137,9 @@ export function obey(predicate: Predicate) { * all(associativity, commutativity, identity) * ``` */ -export function all(...laws: InstanceValidator[]): InstanceValidator { +export function all( + ...laws: InstanceValidator[] +): InstanceValidator { return new All(laws) } @@ -158,6 +155,8 @@ export function all(...laws: InstanceValidator[]): InstanceValidator { * any(symmetry, antisymmetry) * ``` */ -export function any(...laws: InstanceValidator[]): InstanceValidator { +export function any( + ...laws: InstanceValidator[] +): InstanceValidator { return new Any(laws) } diff --git a/test/config.test.ts b/test/config.test.ts deleted file mode 100644 index 850652f..0000000 --- a/test/config.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { config } from '../src/config' - -test('Will throw if one tries to use a negative testSampleSize', () => { - expect(() => { - config.testSampleSize = -2 - }).toThrow() -}) - -test('Will truncate valid testSampleSizes', () => { - const value = (config.testSampleSize = Math.E) - - expect(config.testSampleSize).toBe(Math.trunc(value)) -}) - -test('Will cast skipValidations to boolean', () => { - // @ts-ignore - config.skipValidations = {} - - expect(config.skipValidations).toBe(true) - - // @ts-ignore - config.skipValidations = '' - - expect(config.skipValidations).toBe(false) -}) diff --git a/test/decorators.test.ts b/test/decorators.test.ts deleted file mode 100644 index 858c51b..0000000 --- a/test/decorators.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Class } from '../src/classes' -import { instance } from '../src/decorators' -import { InstanceConstructor, KnownInstanceConstructor } from '../src/instances' -import { metadataKey } from '../src/private' -import { all, obey } from '../src/validators' - -interface Eq { - equals(other: this): boolean -} - -const eq = new Class({ - laws: all( - obey((Instance, value: Eq) => { - return value.equals(value) - }), - ), -}) - -// instances must be explicitly validated -test('instance will mark if validation fails', () => { - expect(() => { - @instance(eq) - class VNumber implements Eq { - constructor(public readonly n: number) {} - - equals(another: VNumber) { - return this.n === another.n - } - - static generateData(x: number) { - return new VNumber(x) - } - } - - const n = new VNumber(Math.PI) - expect(n instanceof VNumber).toBe(true) - - expect((VNumber as KnownInstanceConstructor)[metadataKey]).toMatchObject({ - classes: [eq], - }) - }).not.toThrow() -}) - -test('instance will mark if validation succeeds', () => { - expect(() => { - @instance(eq) - class VNumber implements Eq { - constructor(public readonly n: number) {} - - equals(another: VNumber) { - return this.n === another.n - } - - static generateData(x: number) { - return new VNumber(x) - } - } - - const n = new VNumber(Math.PI) - expect(n instanceof VNumber).toBe(true) - - expect((VNumber as KnownInstanceConstructor)[metadataKey]).toMatchObject({ - classes: [eq], - }) - }).not.toThrow() -}) diff --git a/test/generators.test.ts b/test/generators.test.ts new file mode 100644 index 0000000..df64a0a --- /dev/null +++ b/test/generators.test.ts @@ -0,0 +1,111 @@ +import { continuous, discrete } from '../src/generators' + +class A { + constructor(public readonly x: number, public readonly y: number) {} +} + +describe('continuous', () => { + const originalRandom = Math.random + const mockedRandomValue = (Symbol(42) as any) as number + + beforeEach(() => { + Math.random = () => mockedRandomValue + }) + + afterEach(() => { + Math.random = originalRandom + }) + + it("continuous generates random numbers for it's function", () => { + const c = continuous((x, y) => { + return new A(x, y) + }) + + const gen = c.get(10) + + expect(gen.x).toEqual(mockedRandomValue) + expect(gen.y).toEqual(mockedRandomValue) + }) + + it('continuous generates always 0s for the first position', () => { + const c = continuous((x, y) => { + return new A(x, y) + }) + + const gen = c.get(0) + + expect(gen.x).toEqual(0) + expect(gen.y).toEqual(0) + }) + + it('continuous generates always 1s for the first duration', () => { + const c = continuous((x, y) => { + return new A(x, y) + }) + + const gen = c.get(1) + + expect(gen.x).toEqual(1) + expect(gen.y).toEqual(1) + }) + + it('continuous uses the provided random function', () => { + const mockedRandomValue2 = (Symbol(2013) as any) as number + const random = () => mockedRandomValue2 + + const c = continuous((x, y) => { + return new A(x, y) + }, random) + + const gen = c.get(10) + + expect(gen.x).toEqual(mockedRandomValue2) + expect(gen.y).toEqual(mockedRandomValue2) + }) +}) + +describe('discrete', () => { + const originalRandom = Math.random + const mockedRandomFn = jest.fn() + + // + const FIRST_VALUE = 0.3 + const SECOND_VALUE = 0.6 + const THIRD_VALUE = 0.9 + + beforeEach(() => { + Math.random = mockedRandomFn + }) + + afterEach(() => { + Math.random = originalRandom + }) + + it('discrete generates random values', () => { + const values = [new A(10, 1), new A(100, 2), new A(1000, 3)] + const d = discrete(values) + + mockedRandomFn.mockReturnValueOnce(THIRD_VALUE) + const gen0 = d.get(10) + + expect(gen0).toEqual(values[2]) + + mockedRandomFn.mockReturnValueOnce(FIRST_VALUE) + const gen1 = d.get(10) + + expect(gen1).toEqual(values[0]) + }) + + it('discrete uses the provided random function', () => { + const random = () => SECOND_VALUE + + const values = [new A(10, 1), new A(100, 2), new A(1000, 3)] + const d = discrete(values, random) + + const gen0 = d.get(10) + const gen1 = d.get(10) + + expect(gen0).toEqual(values[1]) + expect(gen1).toEqual(values[1]) + }) +}) diff --git a/test/instances.test.ts b/test/instances.test.ts index 6d0bc88..b69bc7f 100644 --- a/test/instances.test.ts +++ b/test/instances.test.ts @@ -1,6 +1,6 @@ import { Class } from '../src/classes' -import { instance } from '../src/decorators' -import { isInstance, validate } from '../src/instances' +import { continuous, discrete } from '../src/generators' +import { instance } from '../src/instances' import { all, obey } from '../src/validators' interface Eq { @@ -43,131 +43,7 @@ const showEq = new Class({ extends: [eq, show], }) -test('isInstance returns whether the value is an instance of the class', () => { - @instance(eq) - @instance(show) - class VNumber implements Eq { - constructor(public readonly n: number) {} - - equals(another: VNumber) { - return this.n === another.n - } - - show() {} - - static generateData(x: number) { - return new VNumber(x) - } - } - - const showNumber = new VNumber(6) - - expect(isInstance(showNumber, eq)).toBe(true) - expect(isInstance(showNumber, show)).toBe(true) - expect(isInstance(showNumber, neq)).toBe(false) -}) - -test('isInstance returns false if the value is not of an instance of anything', () => { - class VNumber implements Eq { - constructor(public readonly n: number) {} - - equals(another: VNumber) { - return this.n === another.n - } - - show() {} - - static generateData(x: number) { - return new VNumber(x) - } - } - - const showNumber = new VNumber(6) - - expect(isInstance(showNumber, eq)).toBe(false) - expect(isInstance(showNumber, show)).toBe(false) - expect(isInstance(showNumber, neq)).toBe(false) -}) - -test('isInstance works for inherited constructors', () => { - @instance(show) - class Showable implements Show { - constructor(public readonly n: number) {} - - show() {} - - static generateData(x: number) { - return new Showable(x) - } - } - - @instance(eq) - class Eqable extends Showable { - equals(another: Eqable) { - return this.n === another.n - } - - static generateData(x: number) { - return new Eqable(x) - } - } - - const eqable = new Eqable(20) - const showable = new Showable(66) - - expect(isInstance(eqable, show)).toBe(true) - expect(isInstance(eqable, eq)).toBe(true) - expect(isInstance(eqable, neq)).toBe(false) - - expect(isInstance(showable, show)).toBe(true) - expect(isInstance(showable, eq)).toBe(false) - expect(isInstance(showable, neq)).toBe(false) -}) - -interface Semigroup extends Eq { - add(y: Semigroup): this -} - -const semigroup = new Class({ - extends: [eq], - laws: all( - obey((Instance, x: Semigroup, y: Semigroup, z: Semigroup) => { - return x - .add(y) - .add(z) - .equals(x.add(y.add(z))) - }), - ), -}) - -test('isInstance works for inherited classes', () => { - @instance(semigroup) - class NAddition { - constructor(public readonly n: number) {} - - equals(another: NAddition) { - return this.n === another.n - } - - add(y: NAddition) { - return new NAddition(this.n + y.n) - } - - static generateData(x: number) { - return new NAddition(x) - } - } - - const nadd = new NAddition(20) - - expect(isInstance(nadd, semigroup)).toBe(true) - expect(isInstance(nadd, eq)).toBe(true) - expect(isInstance(nadd, neq)).toBe(false) -}) - test('validate will throw if validation fails', () => { - @instance(eq) - @instance(show) class VNumber implements Eq { constructor(public readonly n: number) {} @@ -176,20 +52,16 @@ test('validate will throw if validation fails', () => { } show() {} - - static generateData(x: number) { - return new VNumber(x) - } } + const generateVNumber = continuous((x) => new VNumber(x)) + expect(() => { - validate(VNumber) + instance(VNumber, eq, generateVNumber) }).toThrow() }) test('validate will not throw if validation succeeds', () => { - @instance(eq) - @instance(show) class VNumber implements Eq { constructor(public readonly n: number) {} @@ -198,19 +70,17 @@ test('validate will not throw if validation succeeds', () => { } show() {} - - static generateData(x: number) { - return new VNumber(x) - } } + const generateVNumber = continuous((x) => new VNumber(x)) + expect(() => { - validate(VNumber) + instance(VNumber, eq, generateVNumber) + instance(VNumber, show, generateVNumber) }).not.toThrow() }) test('validate will throw if a parent class fails', () => { - @instance(showEq) class VNumber implements Eq { constructor(public readonly n: number) {} @@ -219,19 +89,16 @@ test('validate will throw if a parent class fails', () => { } show() {} - - static generateData(x: number) { - return new VNumber(x) - } } + const generateVNumber = continuous((x) => new VNumber(x)) + expect(() => { - validate(VNumber) + instance(VNumber, showEq, generateVNumber) }).toThrow() }) test('validate will not throw if no parent class fails', () => { - @instance(showEq) class VNumber implements Eq { constructor(public readonly n: number) {} @@ -240,44 +107,19 @@ test('validate will not throw if no parent class fails', () => { } show() {} - - static generateData(x: number) { - return new VNumber(x) - } } - expect(() => { - validate(VNumber) - }).not.toThrow() -}) - -test('validate throw if the value is not an instance of anything', () => { - class VNumber implements Eq { - constructor(public readonly n: number) {} - - equals(another: VNumber) { - return this.n === another.n - } - - show() {} - - static generateData(x: number) { - return new VNumber(x) - } - } + const generateVNumber = continuous((x) => new VNumber(x)) expect(() => { - validate(VNumber) - }).toThrow() + instance(VNumber, showEq, generateVNumber) + }).not.toThrow() }) test('validate will not check any one class more than once', () => { const showSpy = jest.spyOn(show.laws, 'check') const eqSpy = jest.spyOn(eq.laws, 'check') - @instance(showEq) - @instance(show) - @instance(eq) class VNumber implements Eq { constructor(public readonly n: number) {} @@ -292,7 +134,15 @@ test('validate will not check any one class more than once', () => { } } - validate(VNumber) + const generateVNumber = discrete([ + new VNumber(Math.PI), + new VNumber(Math.E), + new VNumber(Math.SQRT2), + ]) + + instance(VNumber, showEq, generateVNumber) + instance(VNumber, show, generateVNumber) + instance(VNumber, eq, generateVNumber) expect(showSpy).toBeCalledTimes(1) expect(eqSpy).toBeCalledTimes(1) diff --git a/test/readme.test.js b/test/readme.test.js index a3131fd..4994b51 100644 --- a/test/readme.test.js +++ b/test/readme.test.js @@ -3,10 +3,10 @@ test('Readme examples work', () => { const { Class, all, - instance, - isInstance, + continuous, + discrete, obey, - validate, + instance, } = require('../lib/index') const eq = new Class({ @@ -23,7 +23,6 @@ test('Readme examples work', () => { // this is what I've decided to name my class // this option is not necessary, but it helps to improve error messages name: 'Addable', - extends: [eq], // next, we define the properties we expect our instances to have. // we'll start out by using the `all` function to say that, in order to @@ -37,13 +36,11 @@ test('Readme examples work', () => { obey(function commutativity(Instance, x, y) { const a = x.add(y) const b = y.add(x) - return a.equals(b) }), obey(function associativity(Instance, x, y, z) { const a = x.add(y.add(z)) const b = x.add(y).add(z) - return a.equals(b) }), ), @@ -61,25 +58,27 @@ test('Readme examples work', () => { add(other) { return new Number(this.n + other.n) } - - static generateData(n) { - // this is quite a trivial example, we just wrap the n. - // in case you need more random values, just add them as parameters and they - // will be provided - return new Number(n) - } } - // will throw if anything goes bad. - // new instances shall be instantiated using the returned constructor - const VNumber = instance(addable)(Number) + // you may ask for as many parameters as you want, and to each one will be + // assigned a random number between 0 and 1 (inclusive) + // from these numbers you may generate an instance of your constructor + const gen0 = continuous((n) => new Number(n)) + + // note that, to increase the likelihood of catching edge cases, sometimes the + // generated numbers will be all 0s or 1s + + // testing values will be sampled from the given array + const gen1 = discrete([new Number(0), new Number(1), new Number(3)]) - // will throw and Error if it fails - validate(VNumber) + // this method would be more useful if we had a finite number of possible + // values, which is not the case - const n = new VNumber(50) + // will throw an Error if it fails + instance(Number, addable, gen0) + instance(Number, addable, gen1) - const isAddable = isInstance(n, addable) // true, because Numbers are addable - expect(isAddable).toBe(true) + instance(Number, addable, gen0, { sampleSize: 10 }) + instance(Number, addable, gen1, { sampleSize: 10 }) }).not.toThrow() }) diff --git a/test/tsconfig.json b/test/tsconfig.json index 504cd64..0967ef4 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,5 +1 @@ -{ - "compilerOptions": { - "experimentalDecorators": true - } -} +{} diff --git a/test/validators.test.ts b/test/validators.test.ts index 5df720d..6ba4d86 100644 --- a/test/validators.test.ts +++ b/test/validators.test.ts @@ -1,6 +1,4 @@ -jest.mock('../src/config') - -import { config } from '../src/config' +import { continuous } from '../src/generators' import { all, any, obey } from '../src/validators' class EqInstance { @@ -9,38 +7,22 @@ class EqInstance { equals(b: EqInstance): boolean { return this.x === b.x } - - static generateData(x: number): EqInstance { - return new EqInstance(x) - } } class SumInstance extends EqInstance { sum(b: SumInstance): SumInstance { return new SumInstance(this.x + b.x) } - - static generateData(x: number): SumInstance { - return new SumInstance(x) - } } -const defaultGenerateRandom = config.generateRandom -const defaultSkipValidations = config.skipValidations -const defaultTestSampleSize = config.testSampleSize - -beforeEach(() => { - config.generateRandom = defaultGenerateRandom - config.skipValidations = defaultSkipValidations - config.testSampleSize = defaultTestSampleSize -}) +const generateSum = continuous((x) => new SumInstance(x)) test('Obey returns true if the predicate holds', () => { const validator = obey((Instance, a: SumInstance, b: SumInstance) => { return a.sum(b).equals(b.sum(a)) }) - expect(validator.check(SumInstance).isSuccess()).toBe(true) + expect(validator.check(SumInstance, generateSum).isSuccess()).toBe(true) }) test('Obey returns false if the predicate does not hold', () => { @@ -50,45 +32,11 @@ test('Obey returns false if the predicate does not hold', () => { return !a.equals(b) && a.sum(zero).equals(b.sum(zero)) }) - expect(validator.check(SumInstance).isError()).toBe(true) + expect(validator.check(SumInstance, generateSum).isError()).toBe(true) }) -test('Obey tests with all params as 0', () => { - const zero = new SumInstance(0) - - let wasZeroes = false - - const validator = obey((Instance, a: SumInstance, b: SumInstance) => { - if ([a, b].every((x) => x.equals(zero))) { - wasZeroes = true - } - - return !a.equals(b) && a.sum(zero).equals(b.sum(zero)) - }) - - expect(validator.check(SumInstance).isError()).toBe(true) - expect(wasZeroes).toBe(true) -}) - -test('Obey tests with all params as 1', () => { - const one = new SumInstance(1) - - let wasOnes = false - - const validator = obey((Instance, a: SumInstance, b: SumInstance) => { - if ([a, b].every((x) => x.equals(one))) { - wasOnes = true - } - - return a.sum(b).equals(b.sum(a)) - }) - - expect(validator.check(SumInstance).isSuccess()).toBe(true) - expect(wasOnes).toBe(true) -}) - -test('Will run as many tests as it is set in the config', () => { - const qty = (config.testSampleSize = 10) +test('Will run as many tests as it is set in the options', () => { + const qty = 10 const predicate = jest.fn((Instance, a: SumInstance, b: SumInstance) => { return a.sum(b).equals(b.sum(a)) @@ -96,54 +44,20 @@ test('Will run as many tests as it is set in the config', () => { const validator = obey(predicate) - expect(validator.check(SumInstance).isSuccess()).toBe(true) + expect( + validator.check(SumInstance, generateSum, { sampleSize: qty }).isSuccess(), + ).toBe(true) expect(predicate).toBeCalledTimes(qty) predicate.mockClear() - const qty2 = (config.testSampleSize = 6) + const qty2 = 6 - expect(validator.check(SumInstance).isSuccess()).toBe(true) + expect( + validator.check(SumInstance, generateSum, { sampleSize: qty2 }).isSuccess(), + ).toBe(true) expect(predicate).toBeCalledTimes(qty2) }) -test('Will not run any validation if the config says to do so', () => { - config.skipValidations = true - - const predicate = jest.fn((Instance, a: SumInstance, b: SumInstance) => { - return a.sum(b).equals(b.sum(a)) - }) - - const validator = obey(predicate) - - expect(validator.check(SumInstance).isSuccess()).toBe(true) - expect(predicate).not.toBeCalled() -}) - -test('Will use the given generateRandom callable', () => { - const random = -(2 ** 0.5) - config.generateRandom = () => random - - const randomInstance = new SumInstance(random) - // special cases - const zero = new SumInstance(0) - const one = new SumInstance(1) - - let anythingOtherThanExpected = false - - const validator = obey((Instance, a: SumInstance, b: SumInstance) => { - if ( - ![a, b].every((x) => [zero, one, randomInstance].some(x.equals.bind(x))) - ) { - anythingOtherThanExpected = true - } - - return a.sum(b).equals(b.sum(a)) - }) - - expect(validator.check(SumInstance).isSuccess()).toBe(true) - expect(anythingOtherThanExpected).toBe(false) -}) - test('Will provide the Instance constructor as the first parameter', () => { class StaticSumInstance extends SumInstance { static sum(x: StaticSumInstance, y: StaticSumInstance) { @@ -171,7 +85,7 @@ test('All returns true if all validators return true', () => { implement('toString'), ) - expect(validator.check(SumInstance).isSuccess()).toBe(true) + expect(validator.check(SumInstance, generateSum).isSuccess()).toBe(true) }) test('All returns false if some of the validators does not return true', () => { @@ -181,19 +95,19 @@ test('All returns false if some of the validators does not return true', () => { implement('multiply'), ) - expect(validator.check(SumInstance).isError()).toBe(true) + expect(validator.check(SumInstance, generateSum).isError()).toBe(true) }) test('All returns false if none of the validators return true', () => { const validator = all(implement('a'), implement('b'), implement('c')) - expect(validator.check(SumInstance).isError()).toBe(true) + expect(validator.check(SumInstance, generateSum).isError()).toBe(true) }) test('All returns true if no validators are expected', () => { const validator = all() - expect(validator.check(SumInstance).isSuccess()).toBe(true) + expect(validator.check(SumInstance, generateSum).isSuccess()).toBe(true) }) test('Any returns true if all validators return true', () => { @@ -203,7 +117,7 @@ test('Any returns true if all validators return true', () => { implement('toString'), ) - expect(validator.check(SumInstance).isSuccess()).toBe(true) + expect(validator.check(SumInstance, generateSum).isSuccess()).toBe(true) }) test('Any returns true if only some of the validators return true', () => { @@ -213,19 +127,19 @@ test('Any returns true if only some of the validators return true', () => { implement('multiply'), ) - expect(validator.check(SumInstance).isSuccess()).toBe(true) + expect(validator.check(SumInstance, generateSum).isSuccess()).toBe(true) }) test('Any returns false if none of the validators return true', () => { const validator = any(implement('a'), implement('b'), implement('c')) - expect(validator.check(SumInstance).isError()).toBe(true) + expect(validator.check(SumInstance, generateSum).isError()).toBe(true) }) test('Any returns false if no validators are expected', () => { const validator = any() - expect(validator.check(SumInstance).isError()).toBe(true) + expect(validator.check(SumInstance, generateSum).isError()).toBe(true) }) test('Using higher-order validators as argument to another works as expected', () => { @@ -235,7 +149,7 @@ test('Using higher-order validators as argument to another works as expected', ( any(implement('equals'), implement('nequals')), ) - expect(validator1.check(SumInstance).isSuccess()).toBe(true) + expect(validator1.check(SumInstance, generateSum).isSuccess()).toBe(true) const validator2 = any( all(implement('sum'), implement('multiply'), implement('divide')), @@ -243,7 +157,7 @@ test('Using higher-order validators as argument to another works as expected', ( all(implement('sum'), implement('equals')), ) - expect(validator2.check(SumInstance).isSuccess()).toBe(true) + expect(validator2.check(SumInstance, generateSum).isSuccess()).toBe(true) const validator3 = all( implement('sum'), @@ -251,7 +165,7 @@ test('Using higher-order validators as argument to another works as expected', ( any(implement('multiply'), implement('divide'), implement('raise')), ) - expect(validator3.check(SumInstance).isError()).toBe(true) + expect(validator3.check(SumInstance, generateSum).isError()).toBe(true) const validator4 = all( any(all(any(implement('sum'), implement('multiply')))), @@ -259,5 +173,5 @@ test('Using higher-order validators as argument to another works as expected', ( implement('map'), ) - expect(validator4.check(SumInstance).isError()).toBe(true) + expect(validator4.check(SumInstance, generateSum).isError()).toBe(true) }) diff --git a/tsconfig.json b/tsconfig.json index 76ce1be..a6db67f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,15 +2,13 @@ "compilerOptions": { "declaration": true, "declarationMap": true, - "esModuleInterop": true, - "experimentalDecorators": true, "module": "CommonJS", "moduleResolution": "node", "outDir": "lib", "rootDir": "src", "sourceMap": true, "strict": true, - "target": "ES6" + "target": "ES3" }, "exclude": ["node_modules/**/*", "test/**/*", "lib/**/*", "**/__mocks__/**/*"] }