Skip to content

Commit

Permalink
allow test execution to be deferred
Browse files Browse the repository at this point in the history
we should not test if the constructor is a valid instance right on the
spot, people usually have test stages in their pipelines for that kind
of thing
  • Loading branch information
veigaribo committed Apr 13, 2021
1 parent 3ff84d3 commit 31cd414
Show file tree
Hide file tree
Showing 9 changed files with 369 additions and 79 deletions.
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ We'll be referring to the JavaScript classes that implement the behavior of
a type class (and are thus _instances_ of that class) as _constructors_ and
to the instances of those JavaScript classes as _instance values_.

What this library then allows you to do is to check if every constructor follows
the rules defined in the class, allowing you to modularize your tests in a neat
way.

### Classes

A class is what defines the behavior that you want your instances to
Expand Down Expand Up @@ -120,6 +124,14 @@ class Number {
}
```

With that done, you need only to call `validate` on the instance constructor and
some tests will run. You should call this at some point in your tests.

```javascript
// will throw and Error if it fails
validate(Number)
```

## Checking

You may also check whether a value is of an instance of a class using the
Expand All @@ -132,12 +144,17 @@ isInstance(n, addable) // true, because Numbers are addable

## How it works

When you define your constructor using the `@instance` decorator, a sample
of random instance values will be generated using your constructor's
`generateData`, and each 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.
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.

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.
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.

Expand All @@ -151,7 +168,8 @@ class Number {
// ...
}

// will throw if anything goes bad.
// new instances shall be instantiated using the returned constructor
const VNumber = instance(addable)(Number)

validate(VNumber)
```
24 changes: 12 additions & 12 deletions src/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,19 @@ export class Class {
this.id = idCounter++
}

/**
* Checks if something is an instance of this class, not taking parents into
* account.
*
* 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
* @returns
*
* @see {@link validate}
*/
validate(instance: InstanceConstructor): ValidationResult {
const parentResults = MaybeError.foldConjoin(
this.parents.map((parent) => parent.validate(instance)),
)

if (parentResults.isError()) {
return parentResults.conjoin(
MaybeError.fail(
`Class ${instance.name} fails the prerequisites to be a ${this.name}`,
),
)
}

return this.laws.check(instance)
}

Expand Down
59 changes: 24 additions & 35 deletions src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,20 @@ import {
KnownInstanceConstructor,
} from './instances'
import { metadataKey } from './private'
import { MaybeError } from './utils'

/**
* Declares a constructor to be an instance of the given class.
* If the validation fails, an error is thrown.
* 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.
* @throws If the class validations fail.
*
* @example
* ```javascript
* // this should not be a string, JSDoc's fault
* `@instance(semigroup)`
* `@instance(semigroup)` // (this should not be a string)
* class Addition {
* constructor(x) { this.x = x }
* add(y) { return new Addition(this.x + y.x) }
Expand All @@ -32,45 +31,35 @@ import { MaybeError } from './utils'
* // if you cannot use decorators, just call it as a function
* instance(semigroup)(Addition)
* ```
*
* @see {@link validate}
*/
export function instance<T extends InstanceConstructor>(
theClass: Class,
): (Constructor: T) => KnownInstanceConstructor {
return function (Constructor: T): KnownInstanceConstructor {
const result = theClass.validate(Constructor)

if (result.isSuccess()) {
const existingMetadata = (Constructor as any)[metadataKey]
): (Constructor: T) => T & KnownInstanceConstructor {
return function (Constructor: T): T & KnownInstanceConstructor {
const existingMetadata = (Constructor as any)[metadataKey]

const newMetadata = existingMetadata
? { classes: [...existingMetadata.classes, theClass] }
: { classes: [theClass] }
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
class NewClass extends Constructor implements KnownInstance {
public [metadataKey]: InstanceMetadata
public static [metadataKey]: InstanceMetadata

constructor(...args: any[]) {
super(...args)
constructor(...args: any[]) {
super(...args)

// insert the metadata into the values so isInstance may see it
this[metadataKey] = newMetadata!
}
// 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
}

throw new Error(
result.conjoin(
MaybeError.fail(
`${Constructor.name} is not an instance of ${theClass.name}.`,
),
).value!,
)
// 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
}
}
134 changes: 120 additions & 14 deletions src/instances.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Class } from './classes'
import { metadataKey, Metadatable } from './private'
import { MaybeError } from './utils'

export interface InstanceMetadata {
classes: Class[]
Expand All @@ -24,18 +25,123 @@ export interface KnownInstanceConstructor

export interface KnownInstance extends Metadatable<InstanceMetadata> {}

export function isInstance(value: KnownInstance, theClass: Class) {
const classes = value[metadataKey]?.classes

return (
!!classes &&
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))
)
})
)
/**
* 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
*
* @throws If the validation fails.
* @throws If the given value isn't an instance of anything.
*
* @example
* ```javascript
* const show = new Class({
* // ...
* });
*
* `@instance(show)` // (this should not be a string)
* class Showable implements Show {
* // ...
* }
*
* // will throw if it fails
* validate(Showable);
* ```
*/
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()) {
throw new Error(
maybeError.conjoin(
MaybeError.fail(`${Constructor.name} is invalid.`),
).value!,
)
}
}
2 changes: 1 addition & 1 deletion src/private.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const metadataKey = Symbol('Lawful Typeclass Metadata')

export interface Metadatable<T> {
[metadataKey]?: T
[metadataKey]: T
}
2 changes: 1 addition & 1 deletion src/validators.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { config } from './config'
import { InstanceConstructor } from './instances'
import { arrayWithLength, Maybe, MaybeError } from './utils'
import { arrayWithLength, MaybeError } from './utils'

export type ValidationResult = MaybeError

Expand Down
14 changes: 11 additions & 3 deletions test/decorators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,29 @@ const eq = new Class({
),
})

test('instance will throw if validation fails', () => {
// 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
return this.n === another.n
}

static generateData(x: number) {
return new VNumber(x)
}
}
}).toThrow()

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', () => {
Expand Down
Loading

0 comments on commit 31cd414

Please sign in to comment.