From 158a450a133ee6cb3b015fee4197ffa9d868563e Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Thu, 20 Nov 2025 18:33:28 +0000 Subject: [PATCH 1/6] Implement better deep_equals --- ambar-core/src/test.ts | 82 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/ambar-core/src/test.ts b/ambar-core/src/test.ts index 0c3e728..d2049a0 100644 --- a/ambar-core/src/test.ts +++ b/ambar-core/src/test.ts @@ -318,10 +318,8 @@ const expect = { throw new ExpectationFailure(`expected ${a} to equal ${b}`); }, - deep_equals: function equals(ra: T, rb: T): void { - const a = stringify(ra); - const b = stringify(rb); - if (a === b) { + deep_equals: function deep_equals(a: T, b: T): void { + if (deepEquals(a, b)) { return; } throw new ExpectationFailure(`expected '${a}' to equal '${b}'`); @@ -426,3 +424,79 @@ function parseArgs(argv: Array = process.argv.slice(2)): RunOptions { filters: config.matches, }; } + +function deepEquals(a: any, b: any, seen = new WeakMap()) { + // Fast path for strict equality (handles primitives except NaN) + if (a === b) return true; + + // Handle NaN + if (Number.isNaN(a) && Number.isNaN(b)) return true; + + // Null or different types + if ( + a == null || + b == null || + typeof a !== 'object' || + typeof b !== 'object' + ) { + return false; + } + + // Handle cyclic references + if (seen.has(a)) return seen.get(a) === b; + seen.set(a, b); + + // Handle Date + if (a instanceof Date && b instanceof Date) { + return a.getTime() === b.getTime(); + } + + // Handle Array + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEquals(a[i], b[i], seen)) return false; + } + return true; + } + + // Handle Set + if (a instanceof Set && b instanceof Set) { + if (a.size !== b.size) return false; + for (const val of a) { + // sets are tricky; convert to array and check membership + let found = false; + for (const bVal of b) { + if (deepEquals(val, bVal, seen)) { + found = true; + break; + } + } + if (!found) return false; + } + return true; + } + + // Handle Map + if (a instanceof Map && b instanceof Map) { + if (a.size !== b.size) return false; + for (const [key, val] of a) { + if (!b.has(key)) return false; + if (!deepEquals(val, b.get(key), seen)) return false; + } + return true; + } + + // Handle regular objects + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + if (!bKeys.includes(key)) return false; + if (!deepEquals(a[key], b[key], seen)) return false; + } + + return true; +} From dc8d7931d92b254d7d2ddbc026f2d73eb4a9231d Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Thu, 20 Nov 2025 18:47:41 +0000 Subject: [PATCH 2/6] Add toString to Maybe --- ambar-core/src/maybe.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ambar-core/src/maybe.ts b/ambar-core/src/maybe.ts index e2dd304..2e5a9e3 100644 --- a/ambar-core/src/maybe.ts +++ b/ambar-core/src/maybe.ts @@ -53,6 +53,9 @@ class Just implements IMaybe { readonly value : T; constructor(v: T) { this.value = v; } + toString() { + return `Just(${this.value})`; + } isJust() { return true; } isNothing() { return false; } @@ -70,6 +73,9 @@ class Just implements IMaybe { class Nothing implements IMaybe { static new() : Nothing { return new Nothing(); } constructor() {} + toString() { + return "Nothing()"; + } isJust() { return false; } isNothing() { return true; } From 1fc0ec326a52c519eb9608721d6c7c7a2efeb6a9 Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Thu, 20 Nov 2025 18:57:39 +0000 Subject: [PATCH 3/6] Fix encoding and decoding of maybe --- ambar-core/src/json/decoder.ts | 4 ++-- ambar-core/src/json/encoder.ts | 8 ++++---- ambar-core/src/json/schema.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ambar-core/src/json/decoder.ts b/ambar-core/src/json/decoder.ts index 8dbb8dc..6a4fe5d 100644 --- a/ambar-core/src/json/decoder.ts +++ b/ambar-core/src/json/decoder.ts @@ -283,8 +283,8 @@ const oneOf = []>(decoders: T): T[number] => const maybe = (decoder: Decoder): Decoder> => oneOf([ - nullP.map((_) => Nothing()) as Decoder>, - decoder.map(Just), + object({ nothing: object({}) }).map((_) => Nothing()), + object({ just: decoder }).map((v) => Just(v.just)), ]); const nullable = (decoder: Decoder): Decoder> => diff --git a/ambar-core/src/json/encoder.ts b/ambar-core/src/json/encoder.ts index d460a99..d1cb686 100644 --- a/ambar-core/src/json/encoder.ts +++ b/ambar-core/src/json/encoder.ts @@ -89,11 +89,11 @@ const triple = ( return [sA.run(a), sB.run(b), sC.run(c)]; }); -const maybe = ( - encoder: Encoder> -): Encoder>> => +const maybe = (encoder: Encoder): Encoder> => new Encoder((input) => - input instanceof Nothing ? null : encoder.run(input.value) + input instanceof Nothing + ? { nothing: {} } + : { just: encoder.run(input.value) } ); const nullable = (encoder: Encoder): Encoder> => diff --git a/ambar-core/src/json/schema.ts b/ambar-core/src/json/schema.ts index 1191f07..1a9a47c 100644 --- a/ambar-core/src/json/schema.ts +++ b/ambar-core/src/json/schema.ts @@ -127,7 +127,7 @@ const map = (s: Schema): Schema> => (m) => Array.from(m.entries()) ); -const maybe = (s: Schema>): Schema>> => +const maybe = (s: Schema): Schema> => new Schema(D.maybe(s.decoder), E.maybe(s.encoder)); // An object field that may be absent. From 10b05350f75234d44082cc1aaa9ef6b278d7172b Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Thu, 20 Nov 2025 19:03:50 +0000 Subject: [PATCH 4/6] Add model tests --- ambar-core/tests/suites/json.ts | 99 ++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/ambar-core/tests/suites/json.ts b/ambar-core/tests/suites/json.ts index c617561..3b8d3fa 100644 --- a/ambar-core/tests/suites/json.ts +++ b/ambar-core/tests/suites/json.ts @@ -4,20 +4,105 @@ import { test, group, expect } from 'test'; import * as s from 'json/schema'; import * as fc from 'fast-check'; import { Schema } from 'json/schema'; +import { Json } from 'json/types'; +import { Maybe, Nothing, Just, Nullable } from 'maybe'; const tests = group('json', [ group('schema', [ - test('boolean', () => { - fc.assert( - fc.property(fc.boolean(), (v) => { - console.log(v); - roundtrip(s.boolean, v); + test('boolean', () => roundtripTest(s.boolean, fc.boolean())), + test('string', () => roundtripTest(s.string, fc.string())), + test('number', () => roundtripTest(s.number, fc.double())), + test('Array', () => + roundtripTest(s.array(s.number), fc.array(fc.double()))), + test('object', () => + roundtripTest( + s.object({ + bool: s.boolean, + str: s.string, + num: s.number, + }), + fc.record({ + bool: fc.boolean(), + str: fc.string(), + num: fc.double(), }) - ); - }), + )), + test('pair', () => + roundtrip(s.pair(s.string, s.number), ['wat', 2] as [string, number])), + test('tripple', () => + roundtrip(s.triple(s.string, s.number, s.boolean), ['wat', 2, false] as [ + string, + number, + boolean, + ])), + test('map', () => + roundtripTest( + s.map(s.boolean), + fc + .dictionary(genString(), fc.boolean()) + .map((v) => new Map(Object.entries(v))) + )), + test('json', () => roundtripTest(s.json, genJson())), + test('maybe', () => + roundtripTest(s.maybe(s.string), genMaybe(genString()))), + test('Maybe>', () => + roundtripTest( + s.maybe(s.maybe(s.string)), + genMaybe(genMaybe(genString())) + )), + test('nullable', () => + roundtripTest(s.nullable(s.string), genNullable(genString()))), + test('oneOf', () => + roundtripTest( + s.oneOf( + (v): s.Schema => + typeof v == 'string' + ? (s.string as s.Schema) + : (s.boolean as s.Schema), + [ + s.string as s.Schema, + s.boolean as s.Schema, + ] + ), + fc.oneof(genString(), fc.boolean()) + )), ]), ]); +function genString(): fc.Arbitrary { + return fc.string().filter((v) => v !== '__proto__'); +} + +function genNullable( + gen: fc.Arbitrary> +): fc.Arbitrary> { + return fc.oneof(fc.constant(null), gen); +} + +function genMaybe(gen: fc.Arbitrary): fc.Arbitrary> { + return fc.oneof(fc.constant(Nothing()), gen.map(Just)); +} + +function genJson(lvl = 0): fc.Arbitrary { + const maxDepth = 3; + if (lvl > maxDepth) { + return fc.oneof(fc.double(), fc.constant(null), genString(), fc.boolean()); + } + + return fc.oneof( + fc.double(), + fc.constant(null), + genString(), + fc.boolean(), + fc.array(genJson(lvl + 1)), + fc.dictionary(fc.string(), genJson(lvl + 1)) + ); +} + +function roundtripTest(schema: Schema, gen: fc.Arbitrary): void { + fc.assert(fc.property(gen, (v) => roundtrip(schema, v))); +} + function roundtrip(schema: Schema, input: T): void { const encoded = s.encode(schema, input); const decoded = s.decode(schema, encoded); From 0efced7f66ee4065cd78149deac18d5078420fbb Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Thu, 20 Nov 2025 19:43:47 +0000 Subject: [PATCH 5/6] Add optionalNullable and optionalMaybe to encoder --- ambar-core/src/json/encoder.ts | 68 ++++++++++++++++++++++++++++++++-- ambar-core/src/test.ts | 8 ---- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/ambar-core/src/json/encoder.ts b/ambar-core/src/json/encoder.ts index d1cb686..3b08a4a 100644 --- a/ambar-core/src/json/encoder.ts +++ b/ambar-core/src/json/encoder.ts @@ -20,6 +20,8 @@ export { oneOf, both, stringEnum, + optionalNullable, + optionalMaybe, }; import { Maybe, Nothing, Just, Nullable } from '../maybe'; @@ -41,7 +43,7 @@ class Encoder { } type EncoderDef = { - [P in keyof A]: Encoder; + [P in keyof A]: Encoder | OptionalNullable | OptionalMaybe; }; const toAny = (): Encoder => new Encoder((v) => v); @@ -66,8 +68,29 @@ const object = (encoders: EncoderDef): Encoder => const result = {} as JsonObject; for (const field in encoders) { const encoder = encoders[field]; - const encoded = encoder.run(input[field]); - result[field] = encoded; + switch (true) { + case encoder instanceof OptionalNullable: + if (input[field] !== null && input[field] !== undefined) { + const encoded = encoder.present.run(input[field]); + result[field] = encoded; + } + break; + case encoder instanceof OptionalMaybe: + if ( + !(input[field] instanceof Nothing) && + input[field] !== undefined + ) { + const encoded = encoder.present.run(input[field]); + result[field] = encoded; + } + break; + case encoder instanceof Encoder: + const encoded = encoder.run(input[field]); + result[field] = encoded; + break; + default: + encoder satisfies never; + } } return result; @@ -99,6 +122,45 @@ const maybe = (encoder: Encoder): Encoder> => const nullable = (encoder: Encoder): Encoder> => new Encoder((input) => (input === null ? null : encoder.run(input))); +// An encoder for object keys that omits the field if the value is null. +class OptionalNullable { + private constructor(readonly present: Encoder) {} + + static from(e: Encoder>): OptionalNullable { + const encoder = new Encoder((input) => { + if (input == null) { + throw new Error('OptionalNullable called with null'); + } + return e.run(input); + }); + + return new OptionalNullable(encoder); + } +} + +const optionalNullable = ( + encoder: Encoder +): OptionalNullable> => OptionalNullable.from(encoder); + +// An encoder for object keys that omits the field if the value is Nothing. +class OptionalMaybe { + private constructor(readonly present: Encoder) {} + + static from(e: Encoder): OptionalMaybe> { + const encoder = new Encoder>((input) => { + if (input instanceof Nothing) { + throw new Error('OptionalMaybe called with Nothing()'); + } + return e.run(input.value); + }); + + return new OptionalMaybe(encoder); + } +} + +const optionalMaybe = (encoder: Encoder): OptionalMaybe> => + OptionalMaybe.from(encoder); + // Encode a field that may not be there as a maybe. const optional = ( encoder: Encoder> diff --git a/ambar-core/src/test.ts b/ambar-core/src/test.ts index d2049a0..09acae2 100644 --- a/ambar-core/src/test.ts +++ b/ambar-core/src/test.ts @@ -368,14 +368,6 @@ const expect = { }, }; -function stringify(v: any) { - const str = JSON.stringify(v); - if (str.startsWith('Object') || str.startsWith('[Function')) { - throw new Error('Value is not meaningfully stringifiable'); - } - return str; -} - function extractPattern(input: string): { pattern: string; flags: string } { if (input.startsWith('/') && input.lastIndexOf('/') > 0) { const lastSlash = input.lastIndexOf('/'); From 9ff074e8e5d4c2a25a3689058c34e686bf9e7224 Mon Sep 17 00:00:00 2001 From: Marcelo Lazaroni Date: Thu, 20 Nov 2025 21:07:46 +0000 Subject: [PATCH 6/6] Add schema for optional object keys --- ambar-core/src/json/decoder.ts | 57 +++++++++++++++++++++++++++++++-- ambar-core/src/json/encoder.ts | 39 +++++++++++++--------- ambar-core/src/json/schema.ts | 43 ++++++++++++++++++++++++- ambar-core/src/test.ts | 4 ++- ambar-core/tests/suites/json.ts | 47 +++++++++++++++++++++++++-- 5 files changed, 169 insertions(+), 21 deletions(-) diff --git a/ambar-core/src/json/decoder.ts b/ambar-core/src/json/decoder.ts index 6a4fe5d..2336f40 100644 --- a/ambar-core/src/json/decoder.ts +++ b/ambar-core/src/json/decoder.ts @@ -29,6 +29,8 @@ export { Decoder, type DecoderDef, type DecodeResult, + type DecoderOptionalNullable, + type DecoderOptionalMaybe, decode, object, objectMap, @@ -54,6 +56,8 @@ export { optional, succeed, both, + optionalNullable, + optionalMaybe, }; import { Result, Success, Failure, traverse } from '../result'; @@ -158,7 +162,10 @@ const array = (decodeValue: Decoder): Decoder> => }); type DecoderDef = { - [P in keyof A]: Decoder; + [P in keyof A]: + | Decoder + | DecoderOptionalNullable + | DecoderOptionalMaybe; }; // Ignores extra properties. @@ -171,7 +178,24 @@ const object = (decoders: DecoderDef): Decoder => const result = {} as A; for (const field in decoders) { - const decoder = decoders[field]; + let decoder = decoders[field]; + + if (decoder instanceof DecoderOptionalNullable) { + if (obj[field] === undefined) { + // @ts-expect-error: we know this must be a nullable value. + result[field] = null; + continue; + } + decoder = decoder.present; + } else if (decoder instanceof DecoderOptionalMaybe) { + if (obj[field] === undefined) { + // @ts-expect-error: we know this must be a Maybe value. + result[field] = Nothing(); + continue; + } + decoder = decoder.present; + } + const decoded = decoder.run(obj[field]); switch (true) { case decoded instanceof Success: @@ -313,6 +337,35 @@ const optional = (decoder: Decoder): Decoder> => undefinedP.map((_) => Nothing()) as Decoder>, ]); +// Decoder for a field that may not be present. +// If it is absent it will be decoded as 'null'. +class DecoderOptionalNullable { + private constructor(readonly present: Decoder) {} + static from( + d: Decoder> + ): DecoderOptionalNullable> { + return new DecoderOptionalNullable(d); + } +} + +const optionalNullable = ( + decoder: Decoder> +): DecoderOptionalNullable> => + DecoderOptionalNullable.from(decoder); + +// Decoder for a field that may not be present. +// If it is absent it will be decoded as 'Nothing()'. +class DecoderOptionalMaybe { + private constructor(readonly present: Decoder) {} + static from(d: Decoder): DecoderOptionalMaybe> { + return new DecoderOptionalMaybe(d.map(Just)); + } +} + +const optionalMaybe = ( + decoder: Decoder +): DecoderOptionalMaybe> => DecoderOptionalMaybe.from(decoder); + // Define a recursive decoder function rec(f: (p: Decoder) => Decoder): Decoder { const base: Decoder = fail( diff --git a/ambar-core/src/json/encoder.ts b/ambar-core/src/json/encoder.ts index 3b08a4a..1677f97 100644 --- a/ambar-core/src/json/encoder.ts +++ b/ambar-core/src/json/encoder.ts @@ -6,6 +6,8 @@ export { type Infer, Encoder, type EncoderDef, + type EncoderOptionalNullable, + type EncoderOptionalMaybe, json, boolean, number, @@ -43,7 +45,10 @@ class Encoder { } type EncoderDef = { - [P in keyof A]: Encoder | OptionalNullable | OptionalMaybe; + [P in keyof A]: + | Encoder + | EncoderOptionalNullable + | EncoderOptionalMaybe; }; const toAny = (): Encoder => new Encoder((v) => v); @@ -69,13 +74,13 @@ const object = (encoders: EncoderDef): Encoder => for (const field in encoders) { const encoder = encoders[field]; switch (true) { - case encoder instanceof OptionalNullable: + case encoder instanceof EncoderOptionalNullable: if (input[field] !== null && input[field] !== undefined) { const encoded = encoder.present.run(input[field]); result[field] = encoded; } break; - case encoder instanceof OptionalMaybe: + case encoder instanceof EncoderOptionalMaybe: if ( !(input[field] instanceof Nothing) && input[field] !== undefined @@ -123,43 +128,47 @@ const nullable = (encoder: Encoder): Encoder> => new Encoder((input) => (input === null ? null : encoder.run(input))); // An encoder for object keys that omits the field if the value is null. -class OptionalNullable { +class EncoderOptionalNullable { private constructor(readonly present: Encoder) {} - static from(e: Encoder>): OptionalNullable { + static from( + e: Encoder> + ): EncoderOptionalNullable { const encoder = new Encoder((input) => { if (input == null) { - throw new Error('OptionalNullable called with null'); + throw new Error('EncoderOptionalNullable called with null'); } return e.run(input); }); - return new OptionalNullable(encoder); + return new EncoderOptionalNullable(encoder); } } const optionalNullable = ( - encoder: Encoder -): OptionalNullable> => OptionalNullable.from(encoder); + encoder: Encoder> +): EncoderOptionalNullable> => + EncoderOptionalNullable.from(encoder); // An encoder for object keys that omits the field if the value is Nothing. -class OptionalMaybe { +class EncoderOptionalMaybe { private constructor(readonly present: Encoder) {} - static from(e: Encoder): OptionalMaybe> { + static from(e: Encoder): EncoderOptionalMaybe> { const encoder = new Encoder>((input) => { if (input instanceof Nothing) { - throw new Error('OptionalMaybe called with Nothing()'); + throw new Error('EncoderOptionalMaybe called with Nothing()'); } return e.run(input.value); }); - return new OptionalMaybe(encoder); + return new EncoderOptionalMaybe(encoder); } } -const optionalMaybe = (encoder: Encoder): OptionalMaybe> => - OptionalMaybe.from(encoder); +const optionalMaybe = ( + encoder: Encoder +): EncoderOptionalMaybe> => EncoderOptionalMaybe.from(encoder); // Encode a field that may not be there as a maybe. const optional = ( diff --git a/ambar-core/src/json/schema.ts b/ambar-core/src/json/schema.ts index 1a9a47c..d9addba 100644 --- a/ambar-core/src/json/schema.ts +++ b/ambar-core/src/json/schema.ts @@ -18,6 +18,8 @@ export { maybe, nullable, optional, + optionalNullable, + optionalMaybe, stringLiteral, stringEnum, oneOf, @@ -74,7 +76,10 @@ function from(decoder: Decoder, encoder: Encoder): Schema { } type SchemaDef = { - [D in keyof A]: Schema; + [D in keyof A]: + | Schema + | SchemaOptionalNullable + | SchemaOptionalMaybe; }; const json: Schema = new Schema(D.json, E.json); @@ -91,6 +96,42 @@ const both = (left: Schema, right: Schema): Schema<[T, U]> => E.both(left.encoder, right.encoder) ); +// Schema for an object field that may not be present. +// If it is absent: +// - it will be decoded as 'null'. +// - the encoded object will not contain the relevant key +class SchemaOptionalNullable { + constructor( + readonly decoder: D.DecoderOptionalNullable, + readonly encoder: E.EncoderOptionalNullable + ) {} +} + +const optionalNullable = ( + schema: Schema> +): SchemaOptionalNullable> => + new SchemaOptionalNullable( + D.optionalNullable(schema.decoder), + E.optionalNullable(schema.encoder) + ); + +// Schema for an object field that may not be present. +// If it is absent: +// - it will be decoded as 'Nothing()'. +// - the encoded object will not contain the relevant key +class SchemaOptionalMaybe { + constructor( + readonly decoder: D.DecoderOptionalMaybe, + readonly encoder: E.EncoderOptionalMaybe + ) {} +} + +const optionalMaybe = (schema: Schema): SchemaOptionalMaybe> => + new SchemaOptionalMaybe( + D.optionalMaybe(schema.decoder), + E.optionalMaybe(schema.encoder) + ); + function object(def: SchemaDef): Schema { const pdef = {} as DecoderDef; const sdef = {} as EncoderDef; diff --git a/ambar-core/src/test.ts b/ambar-core/src/test.ts index 09acae2..2af0ab8 100644 --- a/ambar-core/src/test.ts +++ b/ambar-core/src/test.ts @@ -322,7 +322,9 @@ const expect = { if (deepEquals(a, b)) { return; } - throw new ExpectationFailure(`expected '${a}' to equal '${b}'`); + throw new ExpectationFailure( + `expected '${JSON.stringify(a)}' to equal '${JSON.stringify(b)}'` + ); }, greater_than: function greater_than(a: T, b: T): void { diff --git a/ambar-core/tests/suites/json.ts b/ambar-core/tests/suites/json.ts index 3b8d3fa..621c33b 100644 --- a/ambar-core/tests/suites/json.ts +++ b/ambar-core/tests/suites/json.ts @@ -66,6 +66,46 @@ const tests = group('json', [ ), fc.oneof(genString(), fc.boolean()) )), + group('optionalMaybe', [ + test('roundtrip field present', () => + roundtrip(s.object({ key: s.optionalMaybe(s.string) }), { + key: Just('present'), + })), + test('roundtrip field absent', () => + roundtrip(s.object({ key: s.optionalMaybe(s.string) }), { + key: Nothing(), + })), + test('removes Nothing key on encoding', () => { + const schema = s.object({ one: s.optionalMaybe(s.string) }); + const encoded = s.encode(schema, { one: Nothing() }); + expect.deep_equals(encoded, {}); + }), + test('keeps Just key on encoding', () => { + const schema = s.object({ one: s.optionalMaybe(s.string) }); + const encoded = s.encode(schema, { one: Just('wat') }); + expect.deep_equals(encoded, { one: 'wat' }); + }), + ]), + group('optionalNullable', [ + test('roundtrip field present', () => + roundtrip(s.object({ key: s.optionalNullable(s.string) }), { + key: 'present', + })), + test('roundtrip field absent', () => + roundtrip(s.object({ key: s.optionalNullable(s.string) }), { + key: null, + })), + test('removes Nothing key on encoding', () => { + const schema = s.object({ one: s.optionalNullable(s.string) }); + const encoded = s.encode(schema, { one: null }); + expect.deep_equals(encoded, {}); + }), + test('keeps Just key on encoding', () => { + const schema = s.object({ one: s.optionalNullable(s.string) }); + const encoded = s.encode(schema, { one: 'wat' }); + expect.deep_equals(encoded, { one: 'wat' }); + }), + ]), ]), ]); @@ -100,7 +140,10 @@ function genJson(lvl = 0): fc.Arbitrary { } function roundtripTest(schema: Schema, gen: fc.Arbitrary): void { - fc.assert(fc.property(gen, (v) => roundtrip(schema, v))); + fc.assert( + fc.property(gen, (v) => roundtrip(schema, v)), + { includeErrorInReport: true } + ); } function roundtrip(schema: Schema, input: T): void { @@ -109,6 +152,6 @@ function roundtrip(schema: Schema, input: T): void { decoded.either( (failure) => expect.fail(`Unable to decode: ${failure}`), - (output) => expect.deep_equals(input, output) + (output) => expect.deep_equals(output, input) ); }