Repository: dubzzz/fast-check
Pull request #1799 (2021-04-26 21:05:20)

✨ Implement unmappers for char arbitraries
<!-- Why is this PR for? -->
<!-- Describe the reason why you opened this PR or give a link towards the associated issue. -->

## In a nutshell

✔️ 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/1799/builds/124089) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 1a2d37dde9048ac7e718528e9dbe229d6e3d3b2a:

|Sandbox| Source |
|--|--|
|[Vanilla](https://codesandbox.io/s/vanilla-ivoi1)| Configuration |
|[fast-check-examples](https://codesandbox.io/s/fast-check-examples-m08wy)| Configuration |

codecov[bot]: # [Codecov](https://codecov.io/gh/dubzzz/fast-check/pull/1799?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) Report
> Merging [#1799](https://codecov.io/gh/dubzzz/fast-check/pull/1799?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) (23e4cdd) into [main](https://codecov.io/gh/dubzzz/fast-check/commit/2e2a1a148aefa840035664f0f4d2b26cc9ee26bf?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) (2e2a1a1) will **increase** coverage by `0.00%`.
> The diff coverage is `98.07%`.

[![Impacted file tree graph](https://codecov.io/gh/dubzzz/fast-check/pull/1799/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/1799?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    #1799   +/-   ##
=======================================
  Coverage   97.24%   97.24%           
=======================================
  Files         129      129           
  Lines        3696     3735   +39     
  Branches      720      738   +18     
=======================================
+ Hits         3594     3632   +38     
- Misses         98       99    +1     
  Partials        4        4           
```

| Flag | Coverage Δ | |
|---|---|---|
| unit-tests | `97.24% <98.07%> (+<0.01%)` | :arrow_up: |
| unit-tests-10.x-Linux | `?` | |
| unit-tests-12.x-Linux | `?` | |
| unit-tests-14.x-Linux | `97.24% <98.07%> (+<0.01%)` | :arrow_up: |

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/1799?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) | Coverage Δ | |
|---|---|---|
| [...y/\_internals/builders/CharacterArbitraryBuilder.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1799/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9faW50ZXJuYWxzL2J1aWxkZXJzL0NoYXJhY3RlckFyYml0cmFyeUJ1aWxkZXIudHM=) | `94.73% <94.44%> (-5.27%)` | :arrow_down: |
| [...rc/arbitrary/\_internals/mappers/PreferPrintable.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1799/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9faW50ZXJuYWxzL21hcHBlcnMvUHJlZmVyUHJpbnRhYmxlLnRz) | `100.00% <100.00%> (ø)` | |
| [src/arbitrary/ascii.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1799/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9hc2NpaS50cw==) | `100.00% <100.00%> (ø)` | |
| [src/arbitrary/base64.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1799/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9iYXNlNjQudHM=) | `100.00% <100.00%> (ø)` | |
| [src/arbitrary/char.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1799/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9jaGFyLnRz) | `100.00% <100.00%> (ø)` | |
| [src/arbitrary/char16bits.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1799/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9jaGFyMTZiaXRzLnRz) | `100.00% <100.00%> (ø)` | |
| [src/arbitrary/fullUnicode.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1799/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9mdWxsVW5pY29kZS50cw==) | `100.00% <100.00%> (ø)` | |
| [src/arbitrary/hexa.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1799/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9oZXhhLnRz) | `100.00% <100.00%> (ø)` | |
| [src/arbitrary/unicode.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1799/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS91bmljb2RlLnRz) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/dubzzz/fast-check/pull/1799?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/1799?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN). Last update [2e2a1a1...23e4cdd](https://codecov.io/gh/dubzzz/fast-check/pull/1799?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/builders/CharacterArbitraryBuilder.ts b/src/arbitrary/_internals/builders/CharacterArbitraryBuilder.ts
index 98ca0263..7e361828 100644
--- a/src/arbitrary/_internals/builders/CharacterArbitraryBuilder.ts
+++ b/src/arbitrary/_internals/builders/CharacterArbitraryBuilder.ts
@@ -1,7 +1,41 @@
 import { Arbitrary } from '../../../check/arbitrary/definition/Arbitrary';
+import { convertFromNext, convertToNext } from '../../../check/arbitrary/definition/Converters';
 import { integer } from '../../integer';
 
 /** @internal */
-export function buildCharacterArbitrary(min: number, max: number, mapToCode: (v: number) => number): Arbitrary<string> {
-  return integer(min, max).map((n) => String.fromCodePoint(mapToCode(n)));
+export function charToCode(c: unknown): number {
+  if (typeof c !== 'string') {
+    throw new Error('Cannot unmap non-string');
+  }
+  if (c.length === 0 || c.length > 2) {
+    throw new Error('Cannot unmap string with more or less than one character');
+  }
+
+  const c1 = c.charCodeAt(0);
+  if (c.length === 1) {
+    return c1; // partial surrogate is ok
+  }
+
+  const c2 = c.charCodeAt(1);
+  if (c1 < 0xd800 || c1 > 0xdbff || c2 < 0xdc00 || c2 > 0xdfff) {
+    throw new Error('Cannot unmap invalid surrogate pairs');
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  return c.codePointAt(0)!;
+}
+
+/** @internal */
+export function buildCharacterArbitrary(
+  min: number,
+  max: number,
+  mapToCode: (v: number) => number,
+  unmapFromCode: (v: number) => number
+): Arbitrary<string> {
+  return convertFromNext(
+    convertToNext(integer(min, max)).map(
+      (n) => String.fromCodePoint(mapToCode(n)),
+      (c) => unmapFromCode(charToCode(c))
+    )
+  );
 }
diff --git a/src/arbitrary/_internals/mappers/PreferPrintable.ts b/src/arbitrary/_internals/mappers/PreferPrintable.ts
index 9d721c96..a5ce69f8 100644
--- a/src/arbitrary/_internals/mappers/PreferPrintable.ts
+++ b/src/arbitrary/_internals/mappers/PreferPrintable.ts
@@ -4,3 +4,10 @@ export function preferPrintableMapper(v: number): number {
   if (v <= 0x7e) return v - 95;
   return v;
 }
+
+/** @internal */
+export function preferPrintableUnmapper(v: number): number {
+  if (v >= 0x20 && v <= 0x7e) return v - 0x20; // v + 0x20
+  if (v >= 0 && v <= 0x1f) return v + 95; // v - 95
+  return v;
+}
diff --git a/src/arbitrary/ascii.ts b/src/arbitrary/ascii.ts
index 5ff7384a..2b9cdd03 100644
--- a/src/arbitrary/ascii.ts
+++ b/src/arbitrary/ascii.ts
@@ -1,6 +1,6 @@
 import { Arbitrary } from '../check/arbitrary/definition/Arbitrary';
 import { buildCharacterArbitrary } from './_internals/builders/CharacterArbitraryBuilder';
-import { preferPrintableMapper } from './_internals/mappers/PreferPrintable';
+import { preferPrintableMapper, preferPrintableUnmapper } from './_internals/mappers/PreferPrintable';
 
 /**
  * For single ascii characters - char code between 0x00 (included) and 0x7f (included)
@@ -8,5 +8,5 @@ import { preferPrintableMapper } from './_internals/mappers/PreferPrintable';
  * @public
  */
 export function ascii(): Arbitrary<string> {
-  return buildCharacterArbitrary(0x00, 0x7f, preferPrintableMapper);
+  return buildCharacterArbitrary(0x00, 0x7f, preferPrintableMapper, preferPrintableUnmapper);
 }
diff --git a/src/arbitrary/base64.ts b/src/arbitrary/base64.ts
index 9408cc6f..9aff4b95 100644
--- a/src/arbitrary/base64.ts
+++ b/src/arbitrary/base64.ts
@@ -9,11 +9,19 @@ function base64Mapper(v: number) {
   return v === 62 ? 43 : 47; // +/
 }
 
+/** @internal */
+function base64Unmapper(v: number) {
+  if (v >= 65 && v <= 90) return v - 65; // A-Z
+  if (v >= 97 && v <= 122) return v - 97 + 26; // a-z
+  if (v >= 48 && v <= 57) return v - 48 + 52; // 0-9
+  return v === 43 ? 62 : v === 47 ? 63 : -1; // +/
+}
+
 /**
  * For single base64 characters - A-Z, a-z, 0-9, + or /
  * @remarks Since 0.0.1
  * @public
  */
 export function base64(): Arbitrary<string> {
-  return buildCharacterArbitrary(0, 63, base64Mapper);
+  return buildCharacterArbitrary(0, 63, base64Mapper, base64Unmapper);
 }
diff --git a/src/arbitrary/char.ts b/src/arbitrary/char.ts
index c7800aa0..f2099579 100644
--- a/src/arbitrary/char.ts
+++ b/src/arbitrary/char.ts
@@ -16,5 +16,5 @@ function identity(v: number) {
  */
 export function char(): Arbitrary<string> {
   // Only printable characters: https://www.ascii-code.com/
-  return buildCharacterArbitrary(0x20, 0x7e, identity);
+  return buildCharacterArbitrary(0x20, 0x7e, identity, identity);
 }
diff --git a/src/arbitrary/char16bits.ts b/src/arbitrary/char16bits.ts
index 64a5f4c3..6a078804 100644
--- a/src/arbitrary/char16bits.ts
+++ b/src/arbitrary/char16bits.ts
@@ -1,6 +1,6 @@
 import { Arbitrary } from '../check/arbitrary/definition/Arbitrary';
 import { buildCharacterArbitrary } from './_internals/builders/CharacterArbitraryBuilder';
-import { preferPrintableMapper } from './_internals/mappers/PreferPrintable';
+import { preferPrintableMapper, preferPrintableUnmapper } from './_internals/mappers/PreferPrintable';
 
 /**
  * For single characters - all values in 0x0000-0xffff can be generated
@@ -14,5 +14,5 @@ import { preferPrintableMapper } from './_internals/mappers/PreferPrintable';
  * @public
  */
 export function char16bits(): Arbitrary<string> {
-  return buildCharacterArbitrary(0x0000, 0xffff, preferPrintableMapper);
+  return buildCharacterArbitrary(0x0000, 0xffff, preferPrintableMapper, preferPrintableUnmapper);
 }
diff --git a/src/arbitrary/fullUnicode.ts b/src/arbitrary/fullUnicode.ts
index 614308c8..a7bdd1fd 100644
--- a/src/arbitrary/fullUnicode.ts
+++ b/src/arbitrary/fullUnicode.ts
@@ -1,6 +1,6 @@
 import { Arbitrary } from '../check/arbitrary/definition/Arbitrary';
 import { buildCharacterArbitrary } from './_internals/builders/CharacterArbitraryBuilder';
-import { preferPrintableMapper } from './_internals/mappers/PreferPrintable';
+import { preferPrintableMapper, preferPrintableUnmapper } from './_internals/mappers/PreferPrintable';
 
 // Characters in the range: U+D800 to U+DFFF
 // are called 'surrogate pairs', they cannot be defined alone and come by pairs
@@ -16,6 +16,13 @@ function unicodeMapper(v: number) {
   return v + gapSize;
 }
 
+/** @internal */
+function unicodeUnmapper(v: number) {
+  if (v < 0xd800) return preferPrintableUnmapper(v);
+  if (v <= 0xdfff) return -1;
+  return v - gapSize;
+}
+
 /**
  * For single unicode characters - any of the code points defined in the unicode standard
  *
@@ -27,5 +34,5 @@ function unicodeMapper(v: number) {
  * @public
  */
 export function fullUnicode(): Arbitrary<string> {
-  return buildCharacterArbitrary(0x0000, 0x10ffff - gapSize, unicodeMapper);
+  return buildCharacterArbitrary(0x0000, 0x10ffff - gapSize, unicodeMapper, unicodeUnmapper);
 }
diff --git a/src/arbitrary/hexa.ts b/src/arbitrary/hexa.ts
index e7ed15eb..d74ec867 100644
--- a/src/arbitrary/hexa.ts
+++ b/src/arbitrary/hexa.ts
@@ -8,11 +8,20 @@ function hexaMapper(v: number): number {
     : v + 97 - 10; // a-f
 }
 
+/** @internal */
+function hexaUnmapper(v: number): number {
+  return v < 58
+    ? v - 48 // 0-9
+    : v < 103
+    ? v - 97 + 10 // a-f
+    : -1; // invalid: out of scope
+}
+
 /**
  * For single hexadecimal characters - 0-9 or a-f
  * @remarks Since 0.0.1
  * @public
  */
 export function hexa(): Arbitrary<string> {
-  return buildCharacterArbitrary(0, 15, hexaMapper);
+  return buildCharacterArbitrary(0, 15, hexaMapper, hexaUnmapper);
 }
diff --git a/src/arbitrary/unicode.ts b/src/arbitrary/unicode.ts
index 2fe8e479..2806a732 100644
--- a/src/arbitrary/unicode.ts
+++ b/src/arbitrary/unicode.ts
@@ -1,6 +1,6 @@
 import { Arbitrary } from '../check/arbitrary/definition/Arbitrary';
 import { buildCharacterArbitrary } from './_internals/builders/CharacterArbitraryBuilder';
-import { preferPrintableMapper } from './_internals/mappers/PreferPrintable';
+import { preferPrintableMapper, preferPrintableUnmapper } from './_internals/mappers/PreferPrintable';
 
 // Characters in the range: U+D800 to U+DFFF
 // are called 'surrogate pairs', they cannot be defined alone and come by pairs
@@ -16,11 +16,18 @@ function unicodeMapper(v: number) {
   return v + gapSize;
 }
 
+/** @internal */
+function unicodeUnmapper(v: number) {
+  if (v < 0xd800) return preferPrintableUnmapper(v);
+  if (v <= 0xdfff) return -1;
+  return v - gapSize;
+}
+
 /**
  * For single unicode characters defined in the BMP plan - char code between 0x0000 (included) and 0xffff (included) and without the range 0xd800 to 0xdfff (surrogate pair characters)
  * @remarks Since 0.0.11
  * @public
  */
 export function unicode(): Arbitrary<string> {
-  return buildCharacterArbitrary(0x0000, 0xffff - gapSize, unicodeMapper);
+  return buildCharacterArbitrary(0x0000, 0xffff - gapSize, unicodeMapper, unicodeUnmapper);
 }


Test patch:
diff --git a/test/unit/arbitrary/CharacterArbitrary.spec.ts b/test/unit/arbitrary/CharacterArbitrary.spec.ts
deleted file mode 100644
index cc3d374c..00000000
--- a/test/unit/arbitrary/CharacterArbitrary.spec.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { char } from '../../../src/arbitrary/char';
-import { ascii } from '../../../src/arbitrary/ascii';
-import { char16bits } from '../../../src/arbitrary/char16bits';
-import { unicode } from '../../../src/arbitrary/unicode';
-import { hexa } from '../../../src/arbitrary/hexa';
-import { base64 } from '../../../src/arbitrary/base64';
-import { fullUnicode } from '../../../src/arbitrary/fullUnicode';
-
-import * as genericHelper from '../check/arbitrary/generic/GenericArbitraryHelper';
-
-const remapCharToIndex = (c: string): number => {
-  const cp = c.codePointAt(0)!;
-  if (cp >= 0x20 && cp <= 0x7e) return cp - 0x20;
-  if (cp < 0x20) return cp + 0x7e - 0x20;
-  return cp;
-};
-const isStrictlySmallerCharacter = (c1: string, c2: string) => remapCharToIndex(c1) < remapCharToIndex(c2);
-
-describe('CharacterArbitrary', () => {
-  describe('char [single printable character]', () => {
-    genericHelper.isValidArbitrary(() => char(), {
-      isStrictlySmallerValue: isStrictlySmallerCharacter,
-      isValidValue: (g: string) => g.length === 1 && 0x20 <= g.charCodeAt(0) && g.charCodeAt(0) <= 0x7e,
-    });
-  });
-  describe('ascii [single ascii character]', () => {
-    genericHelper.isValidArbitrary(() => ascii(), {
-      isStrictlySmallerValue: isStrictlySmallerCharacter,
-      isValidValue: (g: string) => g.length === 1 && 0x00 <= g.charCodeAt(0) && g.charCodeAt(0) <= 0x7f,
-    });
-  });
-  describe('char16bits [single 16 bits character]', () => {
-    genericHelper.isValidArbitrary(() => char16bits(), {
-      isStrictlySmallerValue: isStrictlySmallerCharacter,
-      isValidValue: (g: string) => g.length === 1 && 0x0000 <= g.charCodeAt(0) && g.charCodeAt(0) <= 0xffff,
-    });
-  });
-  describe('unicode [single unicode character of BMP plan]', () => {
-    genericHelper.isValidArbitrary(() => unicode(), {
-      isStrictlySmallerValue: isStrictlySmallerCharacter,
-      isValidValue: (g: string) =>
-        g.length === 1 &&
-        0x0000 <= g.charCodeAt(0) &&
-        g.charCodeAt(0) <= 0xffff &&
-        !(0xd800 <= g.charCodeAt(0) && g.charCodeAt(0) <= 0xdfff) /*surrogate pairs*/,
-    });
-  });
-  describe('fullUnicode [single unicode character]', () => {
-    genericHelper.isValidArbitrary(() => fullUnicode(), {
-      isStrictlySmallerValue: isStrictlySmallerCharacter,
-      isValidValue: (g: string) =>
-        [...g].length === 1 &&
-        0x0000 <= g.codePointAt(0)! &&
-        g.codePointAt(0)! <= 0x10ffff &&
-        !(0xd800 <= g.codePointAt(0)! && g.codePointAt(0)! <= 0xdfff) /*surrogate pairs*/,
-    });
-  });
-  describe('hexa [single hexa character]', () => {
-    genericHelper.isValidArbitrary(() => hexa(), {
-      isStrictlySmallerValue: (c1: string, c2: string) => {
-        const evaluate = (c: string) => ('0' <= c && c <= '9' ? c.charCodeAt(0) - 48 : c.charCodeAt(0) - 87);
-        return evaluate(c1) < evaluate(c2);
-      },
-      isValidValue: (g: string) => g.length === 1 && (('0' <= g && g <= '9') || ('a' <= g && g <= 'f')),
-    });
-  });
-  describe('base64 [single base64 character]', () => {
-    genericHelper.isValidArbitrary(() => base64(), {
-      isStrictlySmallerValue: (c1: string, c2: string) => {
-        const evaluate = (c: string) => {
-          if ('A' <= c && c <= 'Z') return c.charCodeAt(0) - 'A'.charCodeAt(0);
-          if ('a' <= c && c <= 'z') return c.charCodeAt(0) - 'a'.charCodeAt(0) + 26;
-          if ('0' <= c && c <= '9') return c.charCodeAt(0) - '0'.charCodeAt(0) + 52;
-          return c === '+' ? 62 : 63;
-        };
-        return evaluate(c1) < evaluate(c2);
-      },
-      isValidValue: (g: string) =>
-        g.length === 1 &&
-        (('a' <= g && g <= 'z') || ('A' <= g && g <= 'Z') || ('0' <= g && g <= '9') || g === '+' || g === '/'),
-    });
-  });
-});
diff --git a/test/unit/arbitrary/_internals/builders/CharacterArbitraryBuilder.spec.ts b/test/unit/arbitrary/_internals/builders/CharacterArbitraryBuilder.spec.ts
new file mode 100644
index 00000000..88f9c5b8
--- /dev/null
+++ b/test/unit/arbitrary/_internals/builders/CharacterArbitraryBuilder.spec.ts
@@ -0,0 +1,40 @@
+import * as fc from '../../../../../lib/fast-check';
+import { charToCode } from '../../../../../src/arbitrary/_internals/builders/CharacterArbitraryBuilder';
+
+describe('charToCode', () => {
+  it('should reject empty string', () => {
+    // Arrange / Act / Assert
+    expect(() => charToCode('')).toThrowError();
+  });
+
+  it('should reject invalid pairs', () => {
+    // Arrange / Act / Assert
+    expect(() => charToCode('\ud800\ud800')).toThrowError();
+    expect(() => charToCode('a\ud800')).toThrowError();
+    expect(() => charToCode('\udfff\ud800')).toThrowError();
+  });
+
+  it('should reject any longer than 1 code-point strings', () =>
+    fc.assert(
+      fc.property(fc.fullUnicodeString({ minLength: 2 }), (s) => {
+        // Arrange / Act / Assert
+        expect(() => charToCode(s)).toThrowError();
+      })
+    ));
+
+  it('should accept any 1 code-point string', () =>
+    fc.assert(
+      fc.property(fc.fullUnicode(), (c) => {
+        // Arrange / Act / Assert
+        expect(charToCode(c)).toBe(c.codePointAt(0));
+      })
+    ));
+
+  it('should accept any 1 char string (including half-surrogates)', () =>
+    fc.assert(
+      fc.property(fc.char16bits(), (c) => {
+        // Arrange / Act / Assert
+        expect(charToCode(c)).toBe(c.charCodeAt(0));
+      })
+    ));
+});
diff --git a/test/unit/arbitrary/ascii.spec.ts b/test/unit/arbitrary/ascii.spec.ts
new file mode 100644
index 00000000..5550ffc2
--- /dev/null
+++ b/test/unit/arbitrary/ascii.spec.ts
@@ -0,0 +1,112 @@
+import * as fc from '../../../lib/fast-check';
+import { ascii } from '../../../src/arbitrary/ascii';
+
+import { convertFromNext, convertToNext } from '../../../src/check/arbitrary/definition/Converters';
+import { fakeNextArbitrary } from '../check/arbitrary/generic/NextArbitraryHelpers';
+
+import * as CharacterArbitraryBuilderMock from '../../../src/arbitrary/_internals/builders/CharacterArbitraryBuilder';
+import {
+  assertGenerateProducesCorrectValues,
+  assertGenerateProducesSameValueGivenSameSeed,
+  assertGenerateProducesValuesFlaggedAsCanGenerate,
+  assertShrinkProducesCorrectValues,
+  assertShrinkProducesSameValueWithoutInitialContext,
+  assertShrinkProducesStrictlySmallerValue,
+  assertShrinkProducesValuesFlaggedAsCanGenerate,
+} from '../check/arbitrary/generic/NextArbitraryAssertions';
+
+function beforeEachHook() {
+  jest.resetModules();
+  jest.restoreAllMocks();
+  fc.configureGlobal({ beforeEach: beforeEachHook });
+}
+beforeEach(beforeEachHook);
+
+describe('ascii', () => {
+  it('should be able to unmap any mapped value', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(ascii);
+
+    // Act / Assert
+    fc.assert(
+      fc.property(fc.integer({ min, max }), (n) => {
+        expect(unmapFromCode(mapToCode(n))).toBe(n);
+      })
+    );
+  });
+
+  it('should always unmap outside of the range for values it could not have generated', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(ascii);
+    const allPossibleValues = new Set([...Array(max - min + 1)].map((_, i) => mapToCode(i + min)));
+
+    // Act / Assert
+    fc.assert(
+      // [0, 1112063] is the range requested by fullUnicode
+      fc.property(fc.oneof(fc.integer({ min: -1, max: 1112064 }), fc.maxSafeInteger()), (code) => {
+        fc.pre(!allPossibleValues.has(code)); // not a possible code for our mapper
+        const unmapped = unmapFromCode(code);
+        expect(unmapped < min || unmapped > max).toBe(true);
+      })
+    );
+  });
+});
+
+describe('ascii (integration)', () => {
+  const isCorrect = (value: string) => value.length === 1 && 0x00 <= value.charCodeAt(0) && value.charCodeAt(0) <= 0x7f;
+
+  const isStrictlySmaller = (c1: string, c2: string) => remapCharToIndex(c1) < remapCharToIndex(c2);
+
+  const asciiBuilder = () => convertToNext(ascii());
+
+  it('should generate the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(asciiBuilder);
+  });
+
+  it('should only generate correct values', () => {
+    assertGenerateProducesCorrectValues(asciiBuilder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during generate', () => {
+    assertGenerateProducesValuesFlaggedAsCanGenerate(asciiBuilder);
+  });
+
+  it('should shrink towards the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(asciiBuilder);
+  });
+
+  it('should be able to shrink without any context', () => {
+    assertShrinkProducesSameValueWithoutInitialContext(asciiBuilder);
+  });
+
+  it('should only shrink towards correct values', () => {
+    assertShrinkProducesCorrectValues(asciiBuilder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during shrink', () => {
+    assertShrinkProducesValuesFlaggedAsCanGenerate(asciiBuilder);
+  });
+
+  it('should preserve strictly smaller ordering in shrink', () => {
+    assertShrinkProducesStrictlySmallerValue(asciiBuilder, isStrictlySmaller);
+  });
+});
+
+// Helpers
+
+function extractArgumentsForBuildCharacter(build: () => void) {
+  const { instance } = fakeNextArbitrary();
+  const buildCharacterArbitrary = jest.spyOn(CharacterArbitraryBuilderMock, 'buildCharacterArbitrary');
+  buildCharacterArbitrary.mockImplementation(() => convertFromNext(instance));
+
+  build();
+  const [min, max, mapToCode, unmapFromCode] = buildCharacterArbitrary.mock.calls[0];
+  return { min, max, mapToCode, unmapFromCode };
+}
+
+function remapCharToIndex(c: string): number {
+  const cp = c.codePointAt(0)!;
+  if (cp >= 0x20 && cp <= 0x7e) return cp - 0x20;
+  if (cp < 0x20) return cp + 0x7e - 0x20;
+  return cp;
+}
diff --git a/test/unit/arbitrary/base64.spec.ts b/test/unit/arbitrary/base64.spec.ts
new file mode 100644
index 00000000..f1dcd820
--- /dev/null
+++ b/test/unit/arbitrary/base64.spec.ts
@@ -0,0 +1,119 @@
+import * as fc from '../../../lib/fast-check';
+import { base64 } from '../../../src/arbitrary/base64';
+
+import { convertFromNext, convertToNext } from '../../../src/check/arbitrary/definition/Converters';
+import { fakeNextArbitrary } from '../check/arbitrary/generic/NextArbitraryHelpers';
+
+import * as CharacterArbitraryBuilderMock from '../../../src/arbitrary/_internals/builders/CharacterArbitraryBuilder';
+import {
+  assertGenerateProducesCorrectValues,
+  assertGenerateProducesSameValueGivenSameSeed,
+  assertGenerateProducesValuesFlaggedAsCanGenerate,
+  assertShrinkProducesCorrectValues,
+  assertShrinkProducesSameValueWithoutInitialContext,
+  assertShrinkProducesStrictlySmallerValue,
+  assertShrinkProducesValuesFlaggedAsCanGenerate,
+} from '../check/arbitrary/generic/NextArbitraryAssertions';
+
+function beforeEachHook() {
+  jest.resetModules();
+  jest.restoreAllMocks();
+  fc.configureGlobal({ beforeEach: beforeEachHook });
+}
+beforeEach(beforeEachHook);
+
+describe('base64', () => {
+  it('should be able to unmap any mapped value', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(base64);
+
+    // Act / Assert
+    fc.assert(
+      fc.property(fc.integer({ min, max }), (n) => {
+        expect(unmapFromCode(mapToCode(n))).toBe(n);
+      })
+    );
+  });
+
+  it('should always unmap outside of the range for values it could not have generated', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(base64);
+    const allPossibleValues = new Set([...Array(max - min + 1)].map((_, i) => mapToCode(i + min)));
+
+    // Act / Assert
+    fc.assert(
+      // [0, 1112063] is the range requested by fullUnicode
+      fc.property(fc.oneof(fc.integer({ min: -1, max: 1112064 }), fc.maxSafeInteger()), (code) => {
+        fc.pre(!allPossibleValues.has(code)); // not a possible code for our mapper
+        const unmapped = unmapFromCode(code);
+        expect(unmapped < min || unmapped > max).toBe(true);
+      })
+    );
+  });
+});
+
+describe('base64 (integration)', () => {
+  const isCorrect = (value: string) =>
+    value.length === 1 &&
+    (('a' <= value && value <= 'z') ||
+      ('A' <= value && value <= 'Z') ||
+      ('0' <= value && value <= '9') ||
+      value === '+' ||
+      value === '/');
+
+  const isStrictlySmaller = (c1: string, c2: string) => {
+    const evaluate = (c: string) => {
+      if ('A' <= c && c <= 'Z') return c.charCodeAt(0) - 'A'.charCodeAt(0);
+      if ('a' <= c && c <= 'z') return c.charCodeAt(0) - 'a'.charCodeAt(0) + 26;
+      if ('0' <= c && c <= '9') return c.charCodeAt(0) - '0'.charCodeAt(0) + 52;
+      return c === '+' ? 62 : 63;
+    };
+    return evaluate(c1) < evaluate(c2);
+  };
+
+  const base64Builder = () => convertToNext(base64());
+
+  it('should generate the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(base64Builder);
+  });
+
+  it('should only generate correct values', () => {
+    assertGenerateProducesCorrectValues(base64Builder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during generate', () => {
+    assertGenerateProducesValuesFlaggedAsCanGenerate(base64Builder);
+  });
+
+  it('should shrink towards the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(base64Builder);
+  });
+
+  it('should be able to shrink without any context', () => {
+    assertShrinkProducesSameValueWithoutInitialContext(base64Builder);
+  });
+
+  it('should only shrink towards correct values', () => {
+    assertShrinkProducesCorrectValues(base64Builder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during shrink', () => {
+    assertShrinkProducesValuesFlaggedAsCanGenerate(base64Builder);
+  });
+
+  it('should preserve strictly smaller ordering in shrink', () => {
+    assertShrinkProducesStrictlySmallerValue(base64Builder, isStrictlySmaller);
+  });
+});
+
+// Helpers
+
+function extractArgumentsForBuildCharacter(build: () => void) {
+  const { instance } = fakeNextArbitrary();
+  const buildCharacterArbitrary = jest.spyOn(CharacterArbitraryBuilderMock, 'buildCharacterArbitrary');
+  buildCharacterArbitrary.mockImplementation(() => convertFromNext(instance));
+
+  build();
+  const [min, max, mapToCode, unmapFromCode] = buildCharacterArbitrary.mock.calls[0];
+  return { min, max, mapToCode, unmapFromCode };
+}
diff --git a/test/unit/arbitrary/char.spec.ts b/test/unit/arbitrary/char.spec.ts
new file mode 100644
index 00000000..927eca88
--- /dev/null
+++ b/test/unit/arbitrary/char.spec.ts
@@ -0,0 +1,105 @@
+import * as fc from '../../../lib/fast-check';
+import { char } from '../../../src/arbitrary/char';
+
+import { convertFromNext, convertToNext } from '../../../src/check/arbitrary/definition/Converters';
+import { fakeNextArbitrary } from '../check/arbitrary/generic/NextArbitraryHelpers';
+
+import * as CharacterArbitraryBuilderMock from '../../../src/arbitrary/_internals/builders/CharacterArbitraryBuilder';
+import {
+  assertGenerateProducesCorrectValues,
+  assertGenerateProducesSameValueGivenSameSeed,
+  assertGenerateProducesValuesFlaggedAsCanGenerate,
+  assertShrinkProducesCorrectValues,
+  assertShrinkProducesSameValueWithoutInitialContext,
+  assertShrinkProducesStrictlySmallerValue,
+  assertShrinkProducesValuesFlaggedAsCanGenerate,
+} from '../check/arbitrary/generic/NextArbitraryAssertions';
+
+function beforeEachHook() {
+  jest.resetModules();
+  jest.restoreAllMocks();
+  fc.configureGlobal({ beforeEach: beforeEachHook });
+}
+beforeEach(beforeEachHook);
+
+describe('char', () => {
+  it('should be able to unmap any mapped value', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(char);
+
+    // Act / Assert
+    fc.assert(
+      fc.property(fc.integer({ min, max }), (n) => {
+        expect(unmapFromCode(mapToCode(n))).toBe(n);
+      })
+    );
+  });
+
+  it('should always unmap outside of the range for values it could not have generated', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(char);
+    const allPossibleValues = new Set([...Array(max - min + 1)].map((_, i) => mapToCode(i + min)));
+
+    // Act / Assert
+    fc.assert(
+      // [0, 1112063] is the range requested by fullUnicode
+      fc.property(fc.oneof(fc.integer({ min: -1, max: 1112064 }), fc.maxSafeInteger()), (code) => {
+        fc.pre(!allPossibleValues.has(code)); // not a possible code for our mapper
+        const unmapped = unmapFromCode(code);
+        expect(unmapped < min || unmapped > max).toBe(true);
+      })
+    );
+  });
+});
+
+describe('char (integration)', () => {
+  const isCorrect = (value: string) => value.length === 1 && 0x20 <= value.charCodeAt(0) && value.charCodeAt(0) <= 0x7e;
+
+  const isStrictlySmaller = (c1: string, c2: string) => c1.codePointAt(0)! < c2.codePointAt(0)!;
+
+  const charBuilder = () => convertToNext(char());
+
+  it('should generate the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(charBuilder);
+  });
+
+  it('should only generate correct values', () => {
+    assertGenerateProducesCorrectValues(charBuilder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during generate', () => {
+    assertGenerateProducesValuesFlaggedAsCanGenerate(charBuilder);
+  });
+
+  it('should shrink towards the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(charBuilder);
+  });
+
+  it('should be able to shrink without any context', () => {
+    assertShrinkProducesSameValueWithoutInitialContext(charBuilder);
+  });
+
+  it('should only shrink towards correct values', () => {
+    assertShrinkProducesCorrectValues(charBuilder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during shrink', () => {
+    assertShrinkProducesValuesFlaggedAsCanGenerate(charBuilder);
+  });
+
+  it('should preserve strictly smaller ordering in shrink', () => {
+    assertShrinkProducesStrictlySmallerValue(charBuilder, isStrictlySmaller);
+  });
+});
+
+// Helpers
+
+function extractArgumentsForBuildCharacter(build: () => void) {
+  const { instance } = fakeNextArbitrary();
+  const buildCharacterArbitrary = jest.spyOn(CharacterArbitraryBuilderMock, 'buildCharacterArbitrary');
+  buildCharacterArbitrary.mockImplementation(() => convertFromNext(instance));
+
+  build();
+  const [min, max, mapToCode, unmapFromCode] = buildCharacterArbitrary.mock.calls[0];
+  return { min, max, mapToCode, unmapFromCode };
+}
diff --git a/test/unit/arbitrary/char16bits.spec.ts b/test/unit/arbitrary/char16bits.spec.ts
new file mode 100644
index 00000000..53e590bd
--- /dev/null
+++ b/test/unit/arbitrary/char16bits.spec.ts
@@ -0,0 +1,113 @@
+import * as fc from '../../../lib/fast-check';
+import { char16bits } from '../../../src/arbitrary/char16bits';
+
+import { convertFromNext, convertToNext } from '../../../src/check/arbitrary/definition/Converters';
+import { fakeNextArbitrary } from '../check/arbitrary/generic/NextArbitraryHelpers';
+
+import * as CharacterArbitraryBuilderMock from '../../../src/arbitrary/_internals/builders/CharacterArbitraryBuilder';
+import {
+  assertGenerateProducesCorrectValues,
+  assertGenerateProducesSameValueGivenSameSeed,
+  assertGenerateProducesValuesFlaggedAsCanGenerate,
+  assertShrinkProducesCorrectValues,
+  assertShrinkProducesSameValueWithoutInitialContext,
+  assertShrinkProducesStrictlySmallerValue,
+  assertShrinkProducesValuesFlaggedAsCanGenerate,
+} from '../check/arbitrary/generic/NextArbitraryAssertions';
+
+function beforeEachHook() {
+  jest.resetModules();
+  jest.restoreAllMocks();
+  fc.configureGlobal({ beforeEach: beforeEachHook });
+}
+beforeEach(beforeEachHook);
+
+describe('char16bits', () => {
+  it('should be able to unmap any mapped value', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(char16bits);
+
+    // Act / Assert
+    fc.assert(
+      fc.property(fc.integer({ min, max }), (n) => {
+        expect(unmapFromCode(mapToCode(n))).toBe(n);
+      })
+    );
+  });
+
+  it('should always unmap outside of the range for values it could not have generated', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(char16bits);
+    const allPossibleValues = new Set([...Array(max - min + 1)].map((_, i) => mapToCode(i + min)));
+
+    // Act / Assert
+    fc.assert(
+      // [0, 1112063] is the range requested by fullUnicode
+      fc.property(fc.oneof(fc.integer({ min: -1, max: 1112064 }), fc.maxSafeInteger()), (code) => {
+        fc.pre(!allPossibleValues.has(code)); // not a possible code for our mapper
+        const unmapped = unmapFromCode(code);
+        expect(unmapped < min || unmapped > max).toBe(true);
+      })
+    );
+  });
+});
+
+describe('char16bits (integration)', () => {
+  const isCorrect = (value: string) =>
+    value.length === 1 && 0x0000 <= value.charCodeAt(0) && value.charCodeAt(0) <= 0xffff;
+
+  const isStrictlySmaller = (c1: string, c2: string) => remapCharToIndex(c1) < remapCharToIndex(c2);
+
+  const char16bitsBuilder = () => convertToNext(char16bits());
+
+  it('should generate the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(char16bitsBuilder);
+  });
+
+  it('should only generate correct values', () => {
+    assertGenerateProducesCorrectValues(char16bitsBuilder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during generate', () => {
+    assertGenerateProducesValuesFlaggedAsCanGenerate(char16bitsBuilder);
+  });
+
+  it('should shrink towards the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(char16bitsBuilder);
+  });
+
+  it('should be able to shrink without any context', () => {
+    assertShrinkProducesSameValueWithoutInitialContext(char16bitsBuilder);
+  });
+
+  it('should only shrink towards correct values', () => {
+    assertShrinkProducesCorrectValues(char16bitsBuilder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during shrink', () => {
+    assertShrinkProducesValuesFlaggedAsCanGenerate(char16bitsBuilder);
+  });
+
+  it('should preserve strictly smaller ordering in shrink', () => {
+    assertShrinkProducesStrictlySmallerValue(char16bitsBuilder, isStrictlySmaller);
+  });
+});
+
+// Helpers
+
+function extractArgumentsForBuildCharacter(build: () => void) {
+  const { instance } = fakeNextArbitrary();
+  const buildCharacterArbitrary = jest.spyOn(CharacterArbitraryBuilderMock, 'buildCharacterArbitrary');
+  buildCharacterArbitrary.mockImplementation(() => convertFromNext(instance));
+
+  build();
+  const [min, max, mapToCode, unmapFromCode] = buildCharacterArbitrary.mock.calls[0];
+  return { min, max, mapToCode, unmapFromCode };
+}
+
+function remapCharToIndex(c: string): number {
+  const cp = c.codePointAt(0)!;
+  if (cp >= 0x20 && cp <= 0x7e) return cp - 0x20;
+  if (cp < 0x20) return cp + 0x7e - 0x20;
+  return cp;
+}
diff --git a/test/unit/arbitrary/fullUnicode.spec.ts b/test/unit/arbitrary/fullUnicode.spec.ts
new file mode 100644
index 00000000..e50fee58
--- /dev/null
+++ b/test/unit/arbitrary/fullUnicode.spec.ts
@@ -0,0 +1,115 @@
+import * as fc from '../../../lib/fast-check';
+import { fullUnicode } from '../../../src/arbitrary/fullUnicode';
+
+import { convertFromNext, convertToNext } from '../../../src/check/arbitrary/definition/Converters';
+import { fakeNextArbitrary } from '../check/arbitrary/generic/NextArbitraryHelpers';
+
+import * as CharacterArbitraryBuilderMock from '../../../src/arbitrary/_internals/builders/CharacterArbitraryBuilder';
+import {
+  assertGenerateProducesCorrectValues,
+  assertGenerateProducesSameValueGivenSameSeed,
+  assertGenerateProducesValuesFlaggedAsCanGenerate,
+  assertShrinkProducesCorrectValues,
+  assertShrinkProducesSameValueWithoutInitialContext,
+  assertShrinkProducesStrictlySmallerValue,
+  assertShrinkProducesValuesFlaggedAsCanGenerate,
+} from '../check/arbitrary/generic/NextArbitraryAssertions';
+
+function beforeEachHook() {
+  jest.resetModules();
+  jest.restoreAllMocks();
+  fc.configureGlobal({ beforeEach: beforeEachHook });
+}
+beforeEach(beforeEachHook);
+
+describe('fullUnicode', () => {
+  it('should be able to unmap any mapped value', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(fullUnicode);
+
+    // Act / Assert
+    fc.assert(
+      fc.property(fc.integer({ min, max }), (n) => {
+        expect(unmapFromCode(mapToCode(n))).toBe(n);
+      })
+    );
+  });
+
+  it('should always unmap outside of the range for values it could not have generated', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(fullUnicode);
+    const allPossibleValues = new Set([...Array(max - min + 1)].map((_, i) => mapToCode(i + min)));
+
+    // Act / Assert
+    fc.assert(
+      fc.property(fc.maxSafeInteger(), (code) => {
+        fc.pre(!allPossibleValues.has(code)); // not a possible code for our mapper
+        const unmapped = unmapFromCode(code);
+        expect(unmapped < min || unmapped > max).toBe(true);
+      })
+    );
+  });
+});
+
+describe('fullUnicode (integration)', () => {
+  const isCorrect = (value: string) =>
+    [...value].length === 1 &&
+    0x0000 <= value.codePointAt(0)! &&
+    value.codePointAt(0)! <= 0x10ffff &&
+    !(0xd800 <= value.codePointAt(0)! && value.codePointAt(0)! <= 0xdfff); /*surrogate pairs*/
+
+  const isStrictlySmaller = (c1: string, c2: string) => remapCharToIndex(c1) < remapCharToIndex(c2);
+
+  const fullUnicodeBuilder = () => convertToNext(fullUnicode());
+
+  it('should generate the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(fullUnicodeBuilder);
+  });
+
+  it('should only generate correct values', () => {
+    assertGenerateProducesCorrectValues(fullUnicodeBuilder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during generate', () => {
+    assertGenerateProducesValuesFlaggedAsCanGenerate(fullUnicodeBuilder);
+  });
+
+  it('should shrink towards the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(fullUnicodeBuilder);
+  });
+
+  it('should be able to shrink without any context', () => {
+    assertShrinkProducesSameValueWithoutInitialContext(fullUnicodeBuilder);
+  });
+
+  it('should only shrink towards correct values', () => {
+    assertShrinkProducesCorrectValues(fullUnicodeBuilder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during shrink', () => {
+    assertShrinkProducesValuesFlaggedAsCanGenerate(fullUnicodeBuilder);
+  });
+
+  it('should preserve strictly smaller ordering in shrink', () => {
+    assertShrinkProducesStrictlySmallerValue(fullUnicodeBuilder, isStrictlySmaller);
+  });
+});
+
+// Helpers
+
+function extractArgumentsForBuildCharacter(build: () => void) {
+  const { instance } = fakeNextArbitrary();
+  const buildCharacterArbitrary = jest.spyOn(CharacterArbitraryBuilderMock, 'buildCharacterArbitrary');
+  buildCharacterArbitrary.mockImplementation(() => convertFromNext(instance));
+
+  build();
+  const [min, max, mapToCode, unmapFromCode] = buildCharacterArbitrary.mock.calls[0];
+  return { min, max, mapToCode, unmapFromCode };
+}
+
+function remapCharToIndex(c: string): number {
+  const cp = c.codePointAt(0)!;
+  if (cp >= 0x20 && cp <= 0x7e) return cp - 0x20;
+  if (cp < 0x20) return cp + 0x7e - 0x20;
+  return cp;
+}
diff --git a/test/unit/arbitrary/hexa.spec.ts b/test/unit/arbitrary/hexa.spec.ts
new file mode 100644
index 00000000..ff267c8e
--- /dev/null
+++ b/test/unit/arbitrary/hexa.spec.ts
@@ -0,0 +1,109 @@
+import * as fc from '../../../lib/fast-check';
+import { hexa } from '../../../src/arbitrary/hexa';
+
+import { convertFromNext, convertToNext } from '../../../src/check/arbitrary/definition/Converters';
+import { fakeNextArbitrary } from '../check/arbitrary/generic/NextArbitraryHelpers';
+
+import * as CharacterArbitraryBuilderMock from '../../../src/arbitrary/_internals/builders/CharacterArbitraryBuilder';
+import {
+  assertGenerateProducesCorrectValues,
+  assertGenerateProducesSameValueGivenSameSeed,
+  assertGenerateProducesValuesFlaggedAsCanGenerate,
+  assertShrinkProducesCorrectValues,
+  assertShrinkProducesSameValueWithoutInitialContext,
+  assertShrinkProducesStrictlySmallerValue,
+  assertShrinkProducesValuesFlaggedAsCanGenerate,
+} from '../check/arbitrary/generic/NextArbitraryAssertions';
+
+function beforeEachHook() {
+  jest.resetModules();
+  jest.restoreAllMocks();
+  fc.configureGlobal({ beforeEach: beforeEachHook });
+}
+beforeEach(beforeEachHook);
+
+describe('hexa', () => {
+  it('should be able to unmap any mapped value', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(hexa);
+
+    // Act / Assert
+    fc.assert(
+      fc.property(fc.integer({ min, max }), (n) => {
+        expect(unmapFromCode(mapToCode(n))).toBe(n);
+      })
+    );
+  });
+
+  it('should always unmap outside of the range for values it could not have generated', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(hexa);
+    const allPossibleValues = new Set([...Array(max - min + 1)].map((_, i) => mapToCode(i + min)));
+
+    // Act / Assert
+    fc.assert(
+      // [0, 1112063] is the range requested by fullUnicode
+      fc.property(fc.oneof(fc.integer({ min: -1, max: 1112064 }), fc.maxSafeInteger()), (code) => {
+        fc.pre(!allPossibleValues.has(code)); // not a possible code for our mapper
+        const unmapped = unmapFromCode(code);
+        expect(unmapped < min || unmapped > max).toBe(true);
+      })
+    );
+  });
+});
+
+describe('hexa (integration)', () => {
+  const isCorrect = (value: string) =>
+    value.length === 1 && (('0' <= value && value <= '9') || ('a' <= value && value <= 'f'));
+
+  const isStrictlySmaller = (c1: string, c2: string) => {
+    const evaluate = (c: string) => ('0' <= c && c <= '9' ? c.charCodeAt(0) - 48 : c.charCodeAt(0) - 87);
+    return evaluate(c1) < evaluate(c2);
+  };
+
+  const hexaBuilder = () => convertToNext(hexa());
+
+  it('should generate the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(hexaBuilder);
+  });
+
+  it('should only generate correct values', () => {
+    assertGenerateProducesCorrectValues(hexaBuilder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during generate', () => {
+    assertGenerateProducesValuesFlaggedAsCanGenerate(hexaBuilder);
+  });
+
+  it('should shrink towards the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(hexaBuilder);
+  });
+
+  it('should be able to shrink without any context', () => {
+    assertShrinkProducesSameValueWithoutInitialContext(hexaBuilder);
+  });
+
+  it('should only shrink towards correct values', () => {
+    assertShrinkProducesCorrectValues(hexaBuilder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during shrink', () => {
+    assertShrinkProducesValuesFlaggedAsCanGenerate(hexaBuilder);
+  });
+
+  it('should preserve strictly smaller ordering in shrink', () => {
+    assertShrinkProducesStrictlySmallerValue(hexaBuilder, isStrictlySmaller);
+  });
+});
+
+// Helpers
+
+function extractArgumentsForBuildCharacter(build: () => void) {
+  const { instance } = fakeNextArbitrary();
+  const buildCharacterArbitrary = jest.spyOn(CharacterArbitraryBuilderMock, 'buildCharacterArbitrary');
+  buildCharacterArbitrary.mockImplementation(() => convertFromNext(instance));
+
+  build();
+  const [min, max, mapToCode, unmapFromCode] = buildCharacterArbitrary.mock.calls[0];
+  return { min, max, mapToCode, unmapFromCode };
+}
diff --git a/test/unit/arbitrary/unicode.spec.ts b/test/unit/arbitrary/unicode.spec.ts
new file mode 100644
index 00000000..7189a48f
--- /dev/null
+++ b/test/unit/arbitrary/unicode.spec.ts
@@ -0,0 +1,116 @@
+import * as fc from '../../../lib/fast-check';
+import { unicode } from '../../../src/arbitrary/unicode';
+
+import { convertFromNext, convertToNext } from '../../../src/check/arbitrary/definition/Converters';
+import { fakeNextArbitrary } from '../check/arbitrary/generic/NextArbitraryHelpers';
+
+import * as CharacterArbitraryBuilderMock from '../../../src/arbitrary/_internals/builders/CharacterArbitraryBuilder';
+import {
+  assertGenerateProducesCorrectValues,
+  assertGenerateProducesSameValueGivenSameSeed,
+  assertGenerateProducesValuesFlaggedAsCanGenerate,
+  assertShrinkProducesCorrectValues,
+  assertShrinkProducesSameValueWithoutInitialContext,
+  assertShrinkProducesStrictlySmallerValue,
+  assertShrinkProducesValuesFlaggedAsCanGenerate,
+} from '../check/arbitrary/generic/NextArbitraryAssertions';
+
+function beforeEachHook() {
+  jest.resetModules();
+  jest.restoreAllMocks();
+  fc.configureGlobal({ beforeEach: beforeEachHook });
+}
+beforeEach(beforeEachHook);
+
+describe('unicode', () => {
+  it('should be able to unmap any mapped value', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(unicode);
+
+    // Act / Assert
+    fc.assert(
+      fc.property(fc.integer({ min, max }), (n) => {
+        expect(unmapFromCode(mapToCode(n))).toBe(n);
+      })
+    );
+  });
+
+  it('should always unmap outside of the range for values it could not have generated', () => {
+    // Arrange
+    const { min, max, mapToCode, unmapFromCode } = extractArgumentsForBuildCharacter(unicode);
+    const allPossibleValues = new Set([...Array(max - min + 1)].map((_, i) => mapToCode(i + min)));
+
+    // Act / Assert
+    fc.assert(
+      // [0, 1112063] is the range requested by fullUnicode
+      fc.property(fc.oneof(fc.integer({ min: -1, max: 1112064 }), fc.maxSafeInteger()), (code) => {
+        fc.pre(!allPossibleValues.has(code)); // not a possible code for our mapper
+        const unmapped = unmapFromCode(code);
+        expect(unmapped < min || unmapped > max).toBe(true);
+      })
+    );
+  });
+});
+
+describe('unicode (integration)', () => {
+  const isCorrect = (value: string) =>
+    value.length === 1 &&
+    0x0000 <= value.charCodeAt(0) &&
+    value.charCodeAt(0) <= 0xffff &&
+    !(0xd800 <= value.charCodeAt(0) && value.charCodeAt(0) <= 0xdfff); /*surrogate pairs*/
+
+  const isStrictlySmaller = (c1: string, c2: string) => remapCharToIndex(c1) < remapCharToIndex(c2);
+
+  const unicodeBuilder = () => convertToNext(unicode());
+
+  it('should generate the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(unicodeBuilder);
+  });
+
+  it('should only generate correct values', () => {
+    assertGenerateProducesCorrectValues(unicodeBuilder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during generate', () => {
+    assertGenerateProducesValuesFlaggedAsCanGenerate(unicodeBuilder);
+  });
+
+  it('should shrink towards the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(unicodeBuilder);
+  });
+
+  it('should be able to shrink without any context', () => {
+    assertShrinkProducesSameValueWithoutInitialContext(unicodeBuilder);
+  });
+
+  it('should only shrink towards correct values', () => {
+    assertShrinkProducesCorrectValues(unicodeBuilder, isCorrect);
+  });
+
+  it('should recognize values that would have been generated using it during shrink', () => {
+    assertShrinkProducesValuesFlaggedAsCanGenerate(unicodeBuilder);
+  });
+
+  it('should preserve strictly smaller ordering in shrink', () => {
+    assertShrinkProducesStrictlySmallerValue(unicodeBuilder, isStrictlySmaller);
+  });
+});
+
+// Helpers
+
+function extractArgumentsForBuildCharacter(build: () => void) {
+  const { instance } = fakeNextArbitrary();
+  const buildCharacterArbitrary = jest.spyOn(CharacterArbitraryBuilderMock, 'buildCharacterArbitrary');
+  buildCharacterArbitrary.mockImplementation(() => convertFromNext(instance));
+
+  build();
+  const [min, max, mapToCode, unmapFromCode] = buildCharacterArbitrary.mock.calls[0];
+  return { min, max, mapToCode, unmapFromCode };
+}
+
+function remapCharToIndex(c: string): number {
+  const cp = c.codePointAt(0)!;
+  if (cp >= 0x20 && cp <= 0x7e) return cp - 0x20;
+  if (cp < 0x20) return cp + 0x7e - 0x20;
+  return cp;
+}