From 299a1a3d77c1fa861f65928ed618fa84b253ac7e Mon Sep 17 00:00:00 2001 From: Brandon Blaylock Date: Thu, 9 Nov 2023 11:50:32 -0800 Subject: [PATCH] feat: add Schemable implementation for fast-check in contrib fast-check does some nice stuff with property based testing and we can implement Schemable for the fast-check Arbitrary, allowing us to somewhat generate fake data from a Schema. Not sure if the IntersectArbitrary is a mess or not but we'll see. --- contrib/fast-check.ts | 62 ++++++++++++++++++++ examples/schema.ts | 76 ++++++++++++++++++++++++ flake.lock | 6 +- iterable.ts | 2 +- testing/contrib/fast-check.test.ts | 93 ++++++++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 contrib/fast-check.ts create mode 100644 examples/schema.ts create mode 100644 testing/contrib/fast-check.test.ts diff --git a/contrib/fast-check.ts b/contrib/fast-check.ts new file mode 100644 index 0000000..e959f93 --- /dev/null +++ b/contrib/fast-check.ts @@ -0,0 +1,62 @@ +import type { Arbitrary, Random, Stream, Value } from "npm:fast-check@3.14.0"; +import type { + IntersectSchemable, + LiteralSchemable, + Schemable, + TupleSchemable, +} from "../schemable.ts"; +import type { Kind, Out } from "../kind.ts"; + +import * as fc from "npm:fast-check@3.14.0"; +export * from "npm:fast-check@3.14.0"; + +export interface KindArbitrary extends Kind { + readonly kind: Arbitrary>; +} + +export class IntersectArbitrary extends fc.Arbitrary { + constructor(private first: Arbitrary, private second: Arbitrary) { + super(); + } + + generate(mrng: Random, biasFactor: number | undefined): Value { + const fst = this.first.generate(mrng, biasFactor); + const snd = this.second.generate(mrng, biasFactor); + return new fc.Value( + Object.assign({}, fst.value, snd.value), + mrng.nextInt(), + ); + } + + canShrinkWithoutContext(value: unknown): value is U & V { + return this.first.canShrinkWithoutContext(value) && + this.second.canShrinkWithoutContext(value); + } + + shrink(value: U & V, context: unknown): Stream> { + return fc.Stream.of(new fc.Value(value, context)); + } +} + +export const SchemableArbitrary: Schemable = { + unknown: fc.anything, + string: fc.string, + number: fc.float, + boolean: fc.boolean, + literal: fc.constantFrom as LiteralSchemable["literal"], + nullable: fc.option, + undefinable: fc.option, + record: (arb: Arbitrary) => fc.dictionary(fc.string(), arb), + array: fc.array, + tuple: fc.tuple as TupleSchemable["tuple"], + struct: fc.record, + partial: (items) => fc.record(items, { requiredKeys: [] }), + intersect: + ((second) => (first) => + new IntersectArbitrary(first, second)) as IntersectSchemable< + KindArbitrary + >["intersect"], + union: (second) => (first) => fc.oneof(first, second), + + lazy: (_id, builder) => fc.memo(builder)(), +}; diff --git a/examples/schema.ts b/examples/schema.ts new file mode 100644 index 0000000..1fd3ed9 --- /dev/null +++ b/examples/schema.ts @@ -0,0 +1,76 @@ +import { schema } from "../schemable.ts"; +import { SchemableDecoder } from "../decoder.ts"; +import { print, SchemableJsonBuilder } from "../json_schema.ts"; +import { sample, SchemableArbitrary } from "../contrib/fast-check.ts"; +import { pipe } from "../fn.ts"; + +const Vector = schema((s) => s.tuple(s.number(), s.number(), s.number())); + +const Asteroid = schema((s) => + s.struct({ + type: s.literal("asteroid"), + location: Vector(s), + mass: s.number(), + }) +); + +const Planet = schema((s) => + s.struct({ + type: s.literal("planet"), + location: Vector(s), + mass: s.number(), + population: s.number(), + habitable: s.boolean(), + }) +); + +const Rank = schema((s) => + pipe( + s.literal("captain"), + s.union(s.literal("first mate")), + s.union(s.literal("officer")), + s.union(s.literal("ensign")), + ) +); + +const CrewMember = schema((s) => + s.struct({ + name: s.string(), + age: s.number(), + rank: Rank(s), + home: Planet(s), + }) +); + +const Ship = schema((s) => + s.struct({ + type: s.literal("ship"), + location: Vector(s), + mass: s.number(), + name: s.string(), + crew: s.array(CrewMember(s)), + }) +); + +const SpaceObject = schema((s) => + pipe(Asteroid(s), s.union(Planet(s)), s.union(Ship(s))) +); + +const decoder = SpaceObject(SchemableDecoder); +const arbitrary = SpaceObject(SchemableArbitrary); +const json_schema = print(SpaceObject(SchemableJsonBuilder)); + +const rands = sample(arbitrary, 10); +const checks = rands.map(decoder); + +console.log({ json_schema, rands, checks }); + +const intersect = schema((s) => + pipe( + s.struct({ one: s.number() }), + s.intersect(s.partial({ two: s.string() })), + ) +); + +const iarbitrary = intersect(SchemableArbitrary); +console.log("Intersect", sample(iarbitrary, 20)); diff --git a/flake.lock b/flake.lock index 5a67034..ab13deb 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1698890957, - "narHash": "sha256-DJ+SppjpPBoJr0Aro9TAcP3sxApCSieY6BYBCoWGUX8=", + "lastModified": 1700108881, + "narHash": "sha256-+Lqybl8kj0+nD/IlAWPPG/RDTa47gff9nbei0u7BntE=", "owner": "nixos", "repo": "nixpkgs", - "rev": "c082856b850ec60cda9f0a0db2bc7bd8900d708c", + "rev": "7414e9ee0b3e9903c24d3379f577a417f0aae5f1", "type": "github" }, "original": { diff --git a/iterable.ts b/iterable.ts index 0d82a8a..ce658ef 100644 --- a/iterable.ts +++ b/iterable.ts @@ -18,7 +18,7 @@ * @since 2.0.0 */ -import type { $, Kind, Out } from "./kind.ts"; +import type { Kind, Out } from "./kind.ts"; import type { Applicable } from "./applicable.ts"; import type { Combinable } from "./combinable.ts"; import type { Either } from "./either.ts"; diff --git a/testing/contrib/fast-check.test.ts b/testing/contrib/fast-check.test.ts new file mode 100644 index 0000000..c13c6f4 --- /dev/null +++ b/testing/contrib/fast-check.test.ts @@ -0,0 +1,93 @@ +import { assertEquals } from "https://deno.land/std@0.103.0/testing/asserts.ts"; + +import * as F from "../../contrib/fast-check.ts"; +import * as R from "../../refinement.ts"; +import { schema } from "../../schemable.ts"; +import { pipe } from "../../fn.ts"; + +Deno.test("Fast Check Schemable", () => { + const Vector = schema((s) => s.tuple(s.number(), s.number(), s.number())); + + const Asteroid = schema((s) => + s.struct({ + type: s.literal("asteroid"), + location: Vector(s), + mass: s.number(), + tags: s.record(s.boolean()), + }) + ); + + const Planet = schema((s) => + s.struct({ + type: s.literal("planet"), + location: Vector(s), + mass: s.number(), + population: s.number(), + habitable: s.boolean(), + }) + ); + + const Rank = schema((s) => + pipe( + s.literal("captain"), + s.union(s.literal("first mate")), + s.union(s.literal("officer")), + s.union(s.literal("ensign")), + ) + ); + + const CrewMember = schema((s) => + pipe( + s.struct({ + name: s.string(), + age: s.number(), + rank: Rank(s), + home: Planet(s), + }), + s.intersect(s.partial({ + tags: s.record(s.string()), + })), + ) + ); + + const Ship = schema((s) => + s.struct({ + type: s.literal("ship"), + location: Vector(s), + mass: s.number(), + name: s.string(), + crew: s.array(CrewMember(s)), + lazy: s.lazy("lazy", () => s.string()), + }) + ); + + const SpaceObject = schema((s) => + pipe(Asteroid(s), s.union(Planet(s)), s.union(Ship(s))) + ); + + const refinement = SpaceObject(R.SchemableRefinement); + const arbitrary = SpaceObject(F.SchemableArbitrary); + const rands = F.sample(arbitrary, 10); + + for (const rand of rands) { + assertEquals(refinement(rand), true); + } +}); + +Deno.test("Fast Check IntersectArbitrary", () => { + const intersect = new F.IntersectArbitrary( + F.record({ one: F.integer() }), + F.record({ two: F.string() }), + ); + + assertEquals(intersect.canShrinkWithoutContext(null), false); + assertEquals( + intersect.canShrinkWithoutContext({ one: 1, two: "two" }), + false, + ); + + assertEquals( + intersect.shrink({ one: 1, two: "two" }, null), + F.Stream.of(new F.Value({ one: 1, two: "two" }, 0)), + ); +});