All files / src/laws schema.ts

100% Statements 16/16
100% Branches 0/0
100% Functions 9/9
100% Lines 16/16

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154                                                                                              45x 2x   2x                                               45x     2x         2x         2x   2x           101x           52x         52x                       101x           101x         101x                                   45x   6x        
import {Law, LawSet} from '#law'
import {Arbitrary, Equivalence as EQ, Option as OP, pipe, Schema} from 'effect'
import {constFalse, constTrue} from 'effect/Function'
import fc from 'fast-check'
import {equivalenceLaws} from './typeclass/concrete/Equivalence.js'
 
/**
 * Build the [@effect/schema](https://effect.website/docs/guides/schema) laws from
 * a schema.
 * @example
 * // Test schema laws are respected in the “Person” schema.
 * import {checkLaws, schemaLaws} from 'effect-ts-laws'
 * import {Schema, Equivalence as EQ, pipe} from 'effect'
 *
 * // String equality that understand integer equivalences such as
 * // ('+01' === ' 1.0 '), ('NaN' === 'NaN'.), and ('1e1' === '0xA').
 * const equivalence: EQ.Equivalence<string> = (self, that) =>
 *   self === that ||
 *   parseInt(self) === parseInt(that) ||
 *   parseFloat(self) === parseFloat(that)
 *
 * const Age: Schema.Schema<number, string> = pipe(
 *   Schema.String.annotations({equivalence: () => equivalence}),
 *   Schema.parseNumber,
 *   Schema.int(),
 *   Schema.positive(),
 * )
 *
 * // Here “age” is of type number.
 * interface Person extends Schema.Schema.Type<typeof _Person> {}
 *
 * // But here “age” is of type string.
 * interface PersonEncoded extends Schema.Schema.Encoded<typeof _Person> {}
 *
 * const _Person = Schema.Struct({name: Schema.String, age: Age})
 *
 * const Person: Schema.Schema<Person, PersonEncoded> = _Person
 *
 * const laws = schemaLaws(Person)
 *
 * console.table(checkLaws(laws))
 * @typeParam A - Decoded type.
 * @typeParam I - Encoded type.
 * @param schema - The schema under test.
 * @returns The schema laws for the given schema.
 * @category schema laws
 */
export const schemaLaws = <A, I>(schema: Schema.Schema<A, I>): LawSet => {
  const [a, equalsA] = project(schema)
 
  return pipe(
    {a, equalsA, F: equalsA},
    equivalenceLaws,
    LawSet,
  )('Schema', ...encodeDecodeLaws(schema))
}
 
/**
 * **The Rule of Schemas** from
 * [@effect/schema docs](https://effect.website/docs/guides/schema/introduction#the-rule-of-schemas-keeping-encode-and-decode-in-sync):
 * “…_when you perform both encoding and decoding operations_,
 * _you should end up with the original value_.”
 *
 * The reason we test both ways, I.e.: `encode ∘ decode = identity`
 * _and_`decode ∘ encode = identity` is there are fault models that would be
 * entirely ignored if we only tested one direction. Check the
 * [schema laws self-test](https://github.com/middle-ages/effect-ts-laws/tree/main/tests/laws/schema.spec.ts)
 * for one such example.
 * @typeParam A - Decoded type.
 * @typeParam I - Encoded type.
 * @param schema - The schema under test.
 * @returns The encode and decode laws for the given schema.
 * @category schema laws
 */
export const encodeDecodeLaws = <A, I>(
  schema: Schema.Schema<A, I>,
): [Law<[I]>, Law<[A]>] => {
  const encoded: Schema.Schema<I> = Schema.encodedBoundSchema(schema)
 
  const [
    [arbitraryEncoded, encodedEquivalence],
    [arbitraryDecoded, decodedEquivalence],
  ] = [project(encoded), project(schema)]
 
  const [encode, decode]: [
    (decodedElement: A) => OP.Option<I>,
    (encodedElement: I) => OP.Option<A>,
  ] = [Schema.encodeOption(schema), Schema.decodeOption(schema)]
 
  return [
    Law(
      'encode decoded identity',
      'encode ∘ decode = id',
      arbitraryEncoded,
    )((self: I) =>
      pipe(
        self,
        decode,
        OP.match({
          onNone: constTrue,
          onSome: that =>
            pipe(
              that,
              encode,
              OP.match({
                onNone: constFalse,
                onSome: that => encodedEquivalence(self, that),
              }),
            ),
        }),
      ),
    ),
 
    Law(
      'decode encoded identity',
      'decode ∘ encode = id',
      arbitraryDecoded,
    )((self: A) =>
      pipe(
        self,
        encode,
        OP.match({
          onNone: constTrue,
          onSome: that =>
            pipe(
              that,
              decode,
              OP.match({
                onNone: constFalse,
                onSome: that => decodedEquivalence(self, that),
              }),
            ),
        }),
      ),
    ),
  ]
}
 
/**
 * The single boundary where an arbitrary built by `effect` enters our world.
 *
 * `effect` depends on fast-check v3 while we build laws with v4. The two
 * `Arbitrary` types are interchangeable at runtime, but as of fast-check
 * 4.9.0 the `Stream` returned by `shrink` declares a private field, so the
 * v3 type is no longer structurally assignable to the v4 one. Until `effect`
 * moves to v4, we bridge the versions here.
 */
const project = <A, I>(
  schema: Schema.Schema<A, I>,
): [fc.Arbitrary<A>, EQ.Equivalence<A>] => [
  Arbitrary.make(schema) as unknown as fc.Arbitrary<A>,
  Schema.equivalence(schema),
]