Repository: dubzzz/fast-check
Pull request #1836 (2021-05-06 22:09:13)

✨ Add built-in unmapper support onto `mapToConstant`


<!-- Why is this PR for? -->
<!-- Describe the reason why you opened this PR or give a link towards the associated issue. -->

## In a nutshell

Ideally `mapToConstant` should provide a more efficient way to compute the unmapping. For the moment the unmapping will be good enough given it will never be called except in explicit calls to canGenerate or shrink without context.

✔️ New feature
❌ Fix an issue
❌ Documentation improvement
❌ Other: *please explain*

(✔️: yes, ❌: no)

## Potential impacts

None

codesandbox[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/dubzzz/fast-check/pr/1836/builds/127521) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 3ba303381a9121df95acc532219e0eabc8fdc10f:

|Sandbox| Source |
|--|--|
|[Vanilla](https://codesandbox.io/s/vanilla-6fxeb)| Configuration |
|[fast-check-examples](https://codesandbox.io/s/fast-check-examples-70r65)| Configuration |

codecov[bot]: # [Codecov](https://codecov.io/gh/dubzzz/fast-check/pull/1836?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) Report
> Merging [#1836](https://codecov.io/gh/dubzzz/fast-check/pull/1836?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) (3ba3033) into [main](https://codecov.io/gh/dubzzz/fast-check/commit/57f685dd420376885e5fb690f18bcc1a5125d2ad?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) (57f685d) will **decrease** coverage by `0.00%`.
> The diff coverage is `96.00%`.

[![Impacted file tree graph](https://codecov.io/gh/dubzzz/fast-check/pull/1836/graphs/tree.svg?width=650&height=150&src=pr&token=fRi2fJN730&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN)](https://codecov.io/gh/dubzzz/fast-check/pull/1836?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN)

```diff
@@            Coverage Diff             @@
##             main    #1836      +/-   ##
==========================================
- Coverage   97.53%   97.52%   -0.01%     
==========================================
  Files         143      144       +1     
  Lines        3851     3881      +30     
  Branches      700      705       +5     
==========================================
+ Hits         3756     3785      +29     
- Misses         91       92       +1     
  Partials        4        4              
```

| Flag | Coverage Δ | |
|---|---|---|
| unit-tests | `97.52% <96.00%> (-0.01%)` | :arrow_down: |
| unit-tests-10.x-Linux | `?` | |
| unit-tests-12.x-Linux | `?` | |
| unit-tests-14.x-Linux | `97.52% <96.00%> (-0.01%)` | :arrow_down: |
| unit-tests-16.x-Linux | `?` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/dubzzz/fast-check/pull/1836?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) | Coverage Δ | |
|---|---|---|
| [...itrary/\_internals/mappers/IndexToMappedConstant.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1836/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9faW50ZXJuYWxzL21hcHBlcnMvSW5kZXhUb01hcHBlZENvbnN0YW50LnRz) | `96.66% <95.45%> (-3.34%)` | :arrow_down: |
| [src/arbitrary/mapToConstant.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1836/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9tYXBUb0NvbnN0YW50LnRz) | `100.00% <100.00%> (+7.14%)` | :arrow_up: |
| [src/check/arbitrary/LoremArbitrary.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1836/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2NoZWNrL2FyYml0cmFyeS9Mb3JlbUFyYml0cmFyeS50cw==) | | |
| [src/arbitrary/\_internals/mappers/WordsToLorem.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1836/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9faW50ZXJuYWxzL21hcHBlcnMvV29yZHNUb0xvcmVtLnRz) | `100.00% <0.00%> (ø)` | |
| [src/arbitrary/lorem.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1836/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9sb3JlbS50cw==) | `94.73% <0.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/dubzzz/fast-check/pull/1836?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/dubzzz/fast-check/pull/1836?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN). Last update [57f685d...3ba3033](https://codecov.io/gh/dubzzz/fast-check/pull/1836?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN).


Patch:
diff --git a/src/arbitrary/_internals/mappers/IndexToMappedConstant.ts b/src/arbitrary/_internals/mappers/IndexToMappedConstant.ts
index 9450273f..663a1191 100644
--- a/src/arbitrary/_internals/mappers/IndexToMappedConstant.ts
+++ b/src/arbitrary/_internals/mappers/IndexToMappedConstant.ts
@@ -1,7 +1,7 @@
 /** @internal */
 export function indexToMappedConstantMapperFor<T>(
   entries: { num: number; build: (idInGroup: number) => T }[]
-): (index: number) => T {
+): (choiceIndex: number) => T {
   return function indexToMappedConstantMapper(choiceIndex: number): T {
     let idx = -1;
     let numSkips = 0;
@@ -11,3 +11,42 @@ export function indexToMappedConstantMapperFor<T>(
     return entries[idx].build(choiceIndex - numSkips + entries[idx].num);
   };
 }
+
+/** @internal */
+type ReverseMapping = { mapping: Map<unknown, number>; negativeZeroIndex: number | undefined };
+
+/** @internal */
+function buildReverseMapping(entries: { num: number; build: (idInGroup: number) => unknown }[]): ReverseMapping {
+  const reverseMapping: ReverseMapping = { mapping: new Map(), negativeZeroIndex: undefined };
+  let choiceIndex = 0;
+  for (let entryIdx = 0; entryIdx !== entries.length; ++entryIdx) {
+    const entry = entries[entryIdx];
+    for (let idxInEntry = 0; idxInEntry !== entry.num; ++idxInEntry) {
+      const value = entry.build(idxInEntry);
+      if (Object.is(value, -0)) {
+        reverseMapping.negativeZeroIndex = choiceIndex;
+      } else {
+        reverseMapping.mapping.set(value, choiceIndex);
+      }
+      ++choiceIndex;
+    }
+  }
+  return reverseMapping;
+}
+
+/** @internal */
+export function indexToMappedConstantUnmapperFor<T>(
+  entries: { num: number; build: (idInGroup: number) => T }[]
+): (value: unknown) => number {
+  let reverseMapping: ReverseMapping | null = null;
+  return function indexToMappedConstantUnmapper(value: unknown): number {
+    if (reverseMapping === null) {
+      reverseMapping = buildReverseMapping(entries);
+    }
+    const choiceIndex = Object.is(value, -0) ? reverseMapping.negativeZeroIndex : reverseMapping.mapping.get(value);
+    if (choiceIndex === undefined) {
+      throw new Error('Unknown value encountered cannot be built using this mapToConstant');
+    }
+    return choiceIndex;
+  };
+}
diff --git a/src/arbitrary/mapToConstant.ts b/src/arbitrary/mapToConstant.ts
index d61b715d..6e02a5bf 100644
--- a/src/arbitrary/mapToConstant.ts
+++ b/src/arbitrary/mapToConstant.ts
@@ -1,6 +1,10 @@
 import { Arbitrary } from '../check/arbitrary/definition/Arbitrary';
+import { convertFromNext, convertToNext } from '../check/arbitrary/definition/Converters';
 import { nat } from './nat';
-import { indexToMappedConstantMapperFor } from './_internals/mappers/IndexToMappedConstant';
+import {
+  indexToMappedConstantMapperFor,
+  indexToMappedConstantUnmapperFor,
+} from './_internals/mappers/IndexToMappedConstant';
 
 /** @internal */
 function computeNumChoices<T>(options: { num: number; build: (idInGroup: number) => T }[]): number {
@@ -35,5 +39,10 @@ function computeNumChoices<T>(options: { num: number; build: (idInGroup: number)
  */
 export function mapToConstant<T>(...entries: { num: number; build: (idInGroup: number) => T }[]): Arbitrary<T> {
   const numChoices = computeNumChoices(entries);
-  return nat({ max: numChoices - 1 }).map(indexToMappedConstantMapperFor(entries));
+  return convertFromNext(
+    convertToNext(nat({ max: numChoices - 1 })).map(
+      indexToMappedConstantMapperFor(entries),
+      indexToMappedConstantUnmapperFor(entries)
+    )
+  );
 }


Test patch:
diff --git a/test/unit/arbitrary/MapToConstantArbitrary.spec.ts b/test/unit/arbitrary/MapToConstantArbitrary.spec.ts
deleted file mode 100644
index 5a17afe6..00000000
--- a/test/unit/arbitrary/MapToConstantArbitrary.spec.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import * as prand from 'pure-rand';
-import * as fc from '../../../lib/fast-check';
-
-import { mapToConstant } from '../../../src/arbitrary/mapToConstant';
-import { Random } from '../../../src/random/generator/Random';
-import { minMax } from '../check/arbitrary/generic/GenericArbitraryHelper';
-import { nat } from '../../../src/arbitrary/nat';
-
-describe('MapToConstantArbitrary', () => {
-  describe('mapToConstant', () => {
-    it('Single non-zero builder should be equivalent to nat and map', () =>
-      fc.assert(
-        fc.property(
-          fc.integer(),
-          fc.integer(1, Number.MAX_SAFE_INTEGER),
-          minMax(fc.nat(100)),
-          (seed, numValues, others) => {
-            const { min: pos, max: numBuilders } = others;
-            fc.pre(pos < numBuilders);
-
-            const entries: { num: number; build: (v: number) => string }[] = [];
-            for (let idx = 0; idx !== numBuilders; ++idx) {
-              entries.push({ num: idx === pos ? numValues : 0, build: (v: number) => `Builder #${idx}: ${v}` });
-            }
-
-            const refArb = nat(numValues - 1).map((v) => entries[pos].build(v));
-            const arb = mapToConstant(...entries);
-
-            const refMrng = new Random(prand.xorshift128plus(seed));
-            const mrng = new Random(prand.xorshift128plus(seed));
-
-            expect(arb.generate(mrng).value).toEqual(refArb.generate(refMrng).value);
-          }
-        )
-      ));
-    it('Should call the right builder', () =>
-      fc.assert(
-        fc.property(fc.integer(), fc.array(fc.nat(100)), (seed, builderSizes) => {
-          const totalSize = builderSizes.reduce((a, b) => a + b, 0);
-          fc.pre(totalSize > 0);
-
-          const entries: { num: number; build: (v: number) => any }[] = [];
-          for (let idx = 0, currentSize = 0; idx !== builderSizes.length; currentSize += builderSizes[idx], ++idx) {
-            entries.push({ num: builderSizes[idx], build: (v) => v + currentSize });
-          }
-
-          const refArb = nat(totalSize - 1);
-          const arb = mapToConstant(...entries);
-
-          const refMrng = new Random(prand.xorshift128plus(seed));
-          const mrng = new Random(prand.xorshift128plus(seed));
-
-          expect(arb.generate(mrng).value).toEqual(refArb.generate(refMrng).value);
-        })
-      ));
-  });
-});
diff --git a/test/unit/arbitrary/__snapshots__/mapToConstant.spec.ts.snap b/test/unit/arbitrary/__snapshots__/mapToConstant.spec.ts.snap
new file mode 100644
index 00000000..fd918e32
--- /dev/null
+++ b/test/unit/arbitrary/__snapshots__/mapToConstant.spec.ts.snap
@@ -0,0 +1,87 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`mapToConstant (integration) should be able to shrink c given hexa-like entries 1`] = `
+"\\"c\\"
+├> \\"0\\"
+├> \\"6\\"
+|  ├> \\"3\\"
+|  |  └> \\"2\\"
+|  |     └> \\"1\\"
+|  |        └> \\"0\\"
+|  └> \\"5\\"
+|     └> \\"4\\"
+|        └> \\"3\\"
+|           ├> \\"0\\"
+|           └> \\"2\\"
+|              └> \\"1\\"
+|                 └> \\"0\\"
+├> \\"9\\"
+|  └> \\"8\\"
+|     └> \\"7\\"
+|        └> \\"6\\"
+|           ├> \\"0\\"
+|           ├> \\"3\\"
+|           |  └> \\"2\\"
+|           |     └> \\"1\\"
+|           |        └> \\"0\\"
+|           └> \\"5\\"
+|              └> \\"4\\"
+|                 └> \\"3\\"
+|                    ├> \\"0\\"
+|                    └> \\"2\\"
+|                       └> \\"1\\"
+|                          └> \\"0\\"
+└> \\"b\\"
+   └> \\"a\\"
+      └> \\"9\\"
+         ├> \\"0\\"
+         ├> \\"5\\"
+         |  ├> \\"3\\"
+         |  |  └> \\"2\\"
+         |  |     └> \\"1\\"
+         |  |        └> \\"0\\"
+         |  └> \\"4\\"
+         |     └> \\"3\\"
+         |        ├> \\"0\\"
+         |        └> \\"2\\"
+         |           └> \\"1\\"
+         |              └> \\"0\\"
+         ├> \\"7\\"
+         |  └> \\"6\\"
+         |     └> \\"5\\"
+         |        ├> \\"0\\"
+         |        ├> \\"3\\"
+         |        |  └> \\"2\\"
+         |        |     └> \\"1\\"
+         |        |        └> \\"0\\"
+         |        └> \\"4\\"
+         |           └> \\"3\\"
+         |              ├> \\"0\\"
+         |              └> \\"2\\"
+         |                 └> \\"1\\"
+         |                    └> \\"0\\"
+         └> \\"8\\"
+            └> \\"7\\"
+               ├> \\"0\\"
+               ├> \\"4\\"
+               |  ├> \\"2\\"
+               |  |  └> \\"1\\"
+               |  |     └> \\"0\\"
+               |  └> \\"3\\"
+               |     └> \\"2\\"
+               |        ├> \\"0\\"
+               |        └> \\"1\\"
+               |           └> \\"0\\"
+               └> \\"6\\"
+                  └> \\"5\\"
+                     └> \\"4\\"
+                        ├> \\"0\\"
+                        ├> \\"2\\"
+                        |  └> \\"1\\"
+                        |     └> \\"0\\"
+                        └> \\"3\\"
+                           └> \\"2\\"
+                              ├> \\"0\\"
+                              └> \\"1\\"
+                                 └> \\"0\\""
+`;
diff --git a/test/unit/arbitrary/_internals/mappers/IndexToMappedConstant.spec.ts b/test/unit/arbitrary/_internals/mappers/IndexToMappedConstant.spec.ts
new file mode 100644
index 00000000..2aa86771
--- /dev/null
+++ b/test/unit/arbitrary/_internals/mappers/IndexToMappedConstant.spec.ts
@@ -0,0 +1,131 @@
+import {
+  indexToMappedConstantMapperFor,
+  indexToMappedConstantUnmapperFor,
+} from '../../../../../src/arbitrary/_internals/mappers/IndexToMappedConstant';
+
+describe('indexToMappedConstantMapperFor', () => {
+  it.each`
+    index | value
+    ${0}  | ${'A0'}
+    ${1}  | ${'A1'}
+    ${9}  | ${'A9'}
+    ${10} | ${'B0'}
+    ${11} | ${'D0'}
+    ${12} | ${'D1'}
+    ${15} | ${'D4'}
+  `('should properly map index $index to value $value', ({ index, value }) => {
+    // Arrange
+    const entries = [
+      { num: 10, build: (index: number) => `A${index}` },
+      { num: 1, build: (index: number) => `B${index}` },
+      { num: 0, build: (index: number) => `C${index}` },
+      { num: 5, build: (index: number) => `D${index}` },
+    ];
+
+    // Act
+    const computedValue = indexToMappedConstantMapperFor(entries)(index);
+
+    // Assert
+    expect(computedValue).toBe(value);
+  });
+});
+
+describe('indexToMappedConstantUnmapperFor', () => {
+  it.each`
+    index | value
+    ${0}  | ${'A0'}
+    ${1}  | ${'A1'}
+    ${9}  | ${'A9'}
+    ${10} | ${'B0'}
+    ${11} | ${'D0'}
+    ${12} | ${'D1'}
+    ${15} | ${'D4'}
+  `('should properly unmap value $value to index $index', ({ index, value }) => {
+    // Arrange
+    const entries = [
+      { num: 10, build: (index: number) => `A${index}` },
+      { num: 1, build: (index: number) => `B${index}` },
+      { num: 0, build: (index: number) => `C${index}` },
+      { num: 5, build: (index: number) => `D${index}` },
+    ];
+
+    // Act
+    const computedIndex = indexToMappedConstantUnmapperFor(entries)(value);
+
+    // Assert
+    expect(computedIndex).toBe(index);
+  });
+
+  it('should properly unmap 0, -0 and NaN when defined in the mappings', () => {
+    // Arrange
+    const entries = [
+      { num: 10, build: (index: number) => `A${index}` },
+      { num: 1, build: (_index: number) => 0 },
+      { num: 2, build: (index: number) => `C${index}` },
+      { num: 1, build: (_index: number) => -0 },
+      { num: 1, build: (_index: number) => Number.NaN },
+      { num: 5, build: (index: number) => `D${index}` },
+    ];
+
+    // Act
+    const unmapper = indexToMappedConstantUnmapperFor<unknown>(entries);
+    const computedIndexZero = unmapper(0);
+    const computedIndexMinusZero = unmapper(-0);
+    const computedIndexNaN = unmapper(Number.NaN);
+    const computedIndexOther = unmapper('D2');
+
+    // Assert
+    expect(computedIndexZero).toBe(10);
+    expect(computedIndexMinusZero).toBe(13);
+    expect(computedIndexNaN).toBe(14);
+    expect(computedIndexOther).toBe(17);
+  });
+
+  it('should not call any build function when creating the unmapper', () => {
+    // Arrange
+    const entries = [
+      { num: 10, build: jest.fn() },
+      { num: 3, build: jest.fn() },
+    ];
+
+    // Act
+    indexToMappedConstantUnmapperFor(entries);
+
+    // Assert
+    expect(entries[0].build).not.toHaveBeenCalled();
+    expect(entries[1].build).not.toHaveBeenCalled();
+  });
+
+  it('should call all build functions on first call to unmapper', () => {
+    // Arrange
+    const entries = [
+      { num: 10, build: jest.fn().mockReturnValue(0) },
+      { num: 3, build: jest.fn().mockReturnValue(1) },
+    ];
+
+    // Act
+    const unmapper = indexToMappedConstantUnmapperFor(entries);
+    unmapper(0);
+
+    // Assert
+    expect(entries[0].build).toHaveBeenCalledTimes(entries[0].num);
+    expect(entries[1].build).toHaveBeenCalledTimes(entries[1].num);
+  });
+
+  it('should not call again build functions on second call to unmapper', () => {
+    // Arrange
+    const entries = [
+      { num: 10, build: jest.fn().mockReturnValue(0) },
+      { num: 3, build: jest.fn().mockReturnValue(1) },
+    ];
+
+    // Act
+    const unmapper = indexToMappedConstantUnmapperFor(entries);
+    unmapper(0);
+    unmapper(1);
+
+    // Assert
+    expect(entries[0].build).toHaveBeenCalledTimes(entries[0].num);
+    expect(entries[1].build).toHaveBeenCalledTimes(entries[1].num);
+  });
+});
diff --git a/test/unit/arbitrary/mapToConstant.spec.ts b/test/unit/arbitrary/mapToConstant.spec.ts
new file mode 100644
index 00000000..260f5411
--- /dev/null
+++ b/test/unit/arbitrary/mapToConstant.spec.ts
@@ -0,0 +1,121 @@
+import fc from '../../../lib/fast-check';
+import { mapToConstant } from '../../../src/arbitrary/mapToConstant';
+import { convertToNext } from '../../../src/check/arbitrary/definition/Converters';
+import { NextValue } from '../../../src/check/arbitrary/definition/NextValue';
+import {
+  assertGenerateProducesCorrectValues,
+  assertGenerateProducesSameValueGivenSameSeed,
+  assertGenerateProducesValuesFlaggedAsCanGenerate,
+  assertShrinkProducesCorrectValues,
+  assertShrinkProducesSameValueWithoutInitialContext,
+  assertShrinkProducesValuesFlaggedAsCanGenerate,
+} from '../check/arbitrary/generic/NextArbitraryAssertions';
+import { buildNextShrinkTree, renderTree } from '../check/arbitrary/generic/ShrinkTree';
+
+describe('mapToConstant', () => {
+  it('should reject any inputs containing at least one strictly negative entry', () =>
+    fc.assert(
+      fc.property(fc.array(fc.nat()), fc.array(fc.nat()), fc.integer({ max: -1 }), (beforeNeg, afterNeg, neg) => {
+        // Arrange
+        const entries = [...beforeNeg, neg, ...afterNeg].map((num) => ({ num, build: jest.fn() }));
+
+        // Act / Assert
+        expect(() => mapToConstant(...entries)).toThrowError();
+      })
+    ));
+
+  it('should reject any inputs summing to zero', () =>
+    fc.assert(
+      fc.property(fc.nat({ max: 1000 }), (length) => {
+        // Arrange
+        const entries = [...Array(length)].map(() => ({ num: 0, build: jest.fn() }));
+
+        // Act / Assert
+        expect(() => mapToConstant(...entries)).toThrowError();
+      })
+    ));
+
+  it('should accept any inputs not summing to zero and with positive or null values', () =>
+    fc.assert(
+      fc.property(fc.array(fc.nat(), { minLength: 1 }), (nums) => {
+        // Arrange
+        fc.pre(nums.some((n) => n > 0));
+        const entries = nums.map((num) => ({ num, build: jest.fn() }));
+
+        // Act / Assert
+        expect(() => mapToConstant(...entries)).not.toThrowError();
+      })
+    ));
+});
+
+describe('mapToConstant (integration)', () => {
+  type Extra = unknown[][];
+  const extraParameters: fc.Arbitrary<Extra> = fc.array(fc.set(fc.anything(), { minLength: 1 }), { minLength: 1 });
+
+  const isCorrect = (value: unknown, extra: Extra) => {
+    for (const entryCandidate of extra) {
+      if (entryCandidate.includes(value)) {
+        // -0 and 0 are mixed by include
+        return true;
+      }
+    }
+    return false;
+  };
+
+  const mapToConstantBuilder = (extra: Extra) => {
+    const alreadySeenConstants = new Set<unknown>();
+    const entries: { num: number; build: (index: number) => unknown }[] = [];
+    for (const entryCandidate of extra) {
+      // Only keep values (aka constants) never seen before (-0 and 0) will be mixed due to Set
+      const trimmedConstants = entryCandidate.filter((c) => !alreadySeenConstants.has(c));
+      entries.push({ num: trimmedConstants.length, build: (index: number) => trimmedConstants[index] });
+      trimmedConstants.forEach((c) => alreadySeenConstants.add(c));
+    }
+    return convertToNext(mapToConstant(...entries));
+  };
+
+  it('should generate the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(mapToConstantBuilder, { extraParameters });
+  });
+
+  it('should only generate correct values', () => {
+    assertGenerateProducesCorrectValues(mapToConstantBuilder, isCorrect, { extraParameters });
+  });
+
+  it('should recognize values that would have been generated using it during generate', () => {
+    assertGenerateProducesValuesFlaggedAsCanGenerate(mapToConstantBuilder, { extraParameters });
+  });
+
+  it('should shrink towards the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(mapToConstantBuilder, { extraParameters });
+  });
+
+  it('should be able to shrink without any context', () => {
+    assertShrinkProducesSameValueWithoutInitialContext(mapToConstantBuilder, { extraParameters });
+  });
+
+  it('should only shrink towards correct values', () => {
+    assertShrinkProducesCorrectValues(mapToConstantBuilder, isCorrect, { extraParameters });
+  });
+
+  it('should recognize values that would have been generated using it during shrink', () => {
+    assertShrinkProducesValuesFlaggedAsCanGenerate(mapToConstantBuilder, { extraParameters });
+  });
+
+  it('should be able to shrink c given hexa-like entries', () => {
+    // Arrange
+    const arb = convertToNext(
+      mapToConstant(
+        { num: 10, build: (index) => String.fromCodePoint(index + 48) }, // 0-9
+        { num: 6, build: (index) => String.fromCodePoint(index + 97) } // a-f
+      )
+    );
+    const value = new NextValue('c');
+
+    // Act
+    const renderedTree = renderTree(buildNextShrinkTree(arb, value)).join('\n');
+
+    // Assert
+    expect(renderedTree).toMatchSnapshot();
+  });
+});