Repository: dubzzz/fast-check
Pull request #1838 (2021-05-07 19:58:54)

✨ Add built-in unmapper support onto `lorem`
<!-- 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

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

[![Impacted file tree graph](https://codecov.io/gh/dubzzz/fast-check/pull/1838/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/1838?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    #1838      +/-   ##
==========================================
- Coverage   97.52%   97.50%   -0.03%     
==========================================
  Files         144      144              
  Lines        3881     3921      +40     
  Branches      705      717      +12     
==========================================
+ Hits         3785     3823      +38     
- Misses         92       94       +2     
  Partials        4        4              
```

| Flag | Coverage Δ | |
|---|---|---|
| unit-tests | `97.50% <93.33%> (-0.03%)` | :arrow_down: |
| unit-tests-12.x-Linux | `?` | |
| unit-tests-14.x-Linux | `97.50% <93.33%> (-0.03%)` | :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/1838?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN) | Coverage Δ | |
|---|---|---|
| [src/arbitrary/\_internals/mappers/WordsToLorem.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1838/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9faW50ZXJuYWxzL21hcHBlcnMvV29yZHNUb0xvcmVtLnRz) | `93.47% <91.89%> (-6.53%)` | :arrow_down: |
| [src/arbitrary/lorem.ts](https://codecov.io/gh/dubzzz/fast-check/pull/1838/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN#diff-c3JjL2FyYml0cmFyeS9sb3JlbS50cw==) | `100.00% <100.00%> (+5.26%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/dubzzz/fast-check/pull/1838?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/1838?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Nicolas+DUBIEN). Last update [3e2e08e...3111601](https://codecov.io/gh/dubzzz/fast-check/pull/1838?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/WordsToLorem.ts b/src/arbitrary/_internals/mappers/WordsToLorem.ts
index 4a867922..54a202fc 100644
--- a/src/arbitrary/_internals/mappers/WordsToLorem.ts
+++ b/src/arbitrary/_internals/mappers/WordsToLorem.ts
@@ -1,12 +1,30 @@
+import { NextArbitrary } from '../../../check/arbitrary/definition/NextArbitrary';
+
 /** @internal */
 export function wordsToJoinedStringMapper(words: string[]): string {
-  // Strip any coma
+  // Strip any comma
   return words.map((w) => (w[w.length - 1] === ',' ? w.substr(0, w.length - 1) : w)).join(' ');
 }
 
+/** @internal */
+export function wordsToJoinedStringUnmapperFor(wordsArbitrary: NextArbitrary<string>): (value: unknown) => string[] {
+  return function wordsToJoinedStringUnmapper(value: unknown): string[] {
+    if (typeof value !== 'string') {
+      throw new Error('Unsupported type');
+    }
+    const words: string[] = [];
+    for (const candidate of value.split(' ')) {
+      if (wordsArbitrary.canGenerate(candidate)) words.push(candidate);
+      else if (wordsArbitrary.canGenerate(candidate + ',')) words.push(candidate + ',');
+      else throw new Error('Unsupported word');
+    }
+    return words;
+  };
+}
+
 /** @internal */
 export function wordsToSentenceMapper(words: string[]): string {
-  // Strip trailing coma (only)
+  // Strip trailing comma (only)
   let sentence = words.join(' ');
   if (sentence[sentence.length - 1] === ',') {
     sentence = sentence.substr(0, sentence.length - 1);
@@ -14,8 +32,48 @@ export function wordsToSentenceMapper(words: string[]): string {
   return sentence[0].toUpperCase() + sentence.substring(1) + '.';
 }
 
+/** @internal */
+export function wordsToSentenceUnmapperFor(wordsArbitrary: NextArbitrary<string>): (value: unknown) => string[] {
+  return function wordsToSentenceUnmapper(value: unknown): string[] {
+    if (typeof value !== 'string') {
+      throw new Error('Unsupported type');
+    }
+    if (
+      value.length < 2 ||
+      value[value.length - 1] !== '.' ||
+      value[value.length - 2] === ',' ||
+      value[0].toLowerCase().toUpperCase() !== value[0]
+    ) {
+      throw new Error('Unsupported value');
+    }
+    const adaptedValue = value[0].toLowerCase() + value.substring(1, value.length - 1);
+    const words: string[] = [];
+    const candidates = adaptedValue.split(' ');
+    for (let idx = 0; idx !== candidates.length; ++idx) {
+      const candidate = candidates[idx];
+      if (wordsArbitrary.canGenerate(candidate)) words.push(candidate);
+      else if (idx === candidates.length - 1 && wordsArbitrary.canGenerate(candidate + ','))
+        words.push(candidate + ',');
+      else throw new Error('Unsupported word');
+    }
+    return words;
+  };
+}
+
 /** @internal */
 export function sentencesToParagraphMapper(sentences: string[]): string {
   // Sentences are supposed to always end by a '.' and not contain any other '.'
   return sentences.join(' ');
 }
+
+/** @internal */
+export function sentencesToParagraphUnmapper(value: unknown): string[] {
+  if (typeof value !== 'string') {
+    throw new Error('Unsupported type');
+  }
+  const sentences = value.split('. ');
+  for (let idx = 0; idx < sentences.length - 1; ++idx) {
+    sentences[idx] += '.'; // re-add removed '.'
+  }
+  return sentences;
+}
diff --git a/src/arbitrary/lorem.ts b/src/arbitrary/lorem.ts
index d7762c64..6eaab084 100644
--- a/src/arbitrary/lorem.ts
+++ b/src/arbitrary/lorem.ts
@@ -4,9 +4,13 @@ import { Arbitrary } from '../check/arbitrary/definition/Arbitrary';
 import { frequency } from './frequency';
 import {
   sentencesToParagraphMapper,
+  sentencesToParagraphUnmapper,
   wordsToJoinedStringMapper,
+  wordsToJoinedStringUnmapperFor,
   wordsToSentenceMapper,
+  wordsToSentenceUnmapperFor,
 } from './_internals/mappers/WordsToLorem';
+import { convertFromNext, convertToNext } from '../check/arbitrary/definition/Converters';
 
 /**
  * Constraints to be applied on {@link lorem}
@@ -64,8 +68,8 @@ const h = (v: string, w: number) => {
  *
  * @internal
  */
-const loremWord = () =>
-  frequency(
+function loremWord() {
+  return frequency(
     h('non', 6),
     h('adipiscing', 5),
     h('ligula', 5),
@@ -217,6 +221,7 @@ const loremWord = () =>
     h('augue,', 1),
     h('lacus', 1)
   );
+}
 
 /**
  * For lorem ipsum strings of words
@@ -267,11 +272,26 @@ function lorem(...args: [] | [number] | [number, boolean] | [LoremConstraints]):
   if (maxCount < 1) {
     throw new Error(`lorem has to produce at least one word/sentence`);
   }
+  const wordArbitrary = loremWord();
+  const wordArbitraryNext = convertToNext(wordArbitrary);
   if (sentencesMode) {
-    const sentence = array(loremWord(), { minLength: 1 }).map(wordsToSentenceMapper);
-    return array(sentence, { minLength: 1, maxLength: maxCount }).map(sentencesToParagraphMapper);
+    const sentence = convertToNext(array(wordArbitrary, { minLength: 1 })).map(
+      wordsToSentenceMapper,
+      wordsToSentenceUnmapperFor(wordArbitraryNext)
+    );
+    return convertFromNext(
+      convertToNext(array(convertFromNext(sentence), { minLength: 1, maxLength: maxCount })).map(
+        sentencesToParagraphMapper,
+        sentencesToParagraphUnmapper
+      )
+    );
   } else {
-    return array(loremWord(), { minLength: 1, maxLength: maxCount }).map(wordsToJoinedStringMapper);
+    return convertFromNext(
+      convertToNext(array(wordArbitrary, { minLength: 1, maxLength: maxCount })).map(
+        wordsToJoinedStringMapper,
+        wordsToJoinedStringUnmapperFor(wordArbitraryNext)
+      )
+    );
   }
 }
 


Test patch:
diff --git a/test/unit/arbitrary/LoremArbitrary.spec.ts b/test/unit/arbitrary/LoremArbitrary.spec.ts
deleted file mode 100644
index 1ab214ad..00000000
--- a/test/unit/arbitrary/LoremArbitrary.spec.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import * as prand from 'pure-rand';
-import * as fc from '../../../lib/fast-check';
-
-import { lorem } from '../../../src/arbitrary/lorem';
-import { Random } from '../../../src/random/generator/Random';
-
-import { generateOneValue } from '../check/arbitrary/generic/GenerateOneValue';
-
-describe('LoremArbitrary', () => {
-  describe('lorem', () => {
-    it('Should generate the same text with the same random', () =>
-      fc.assert(
-        fc.property(fc.integer(), (seed) => {
-          const mrng1 = new Random(prand.xorshift128plus(seed));
-          const mrng2 = new Random(prand.xorshift128plus(seed));
-          const g1 = lorem().generate(mrng1).value;
-          const g2 = lorem().generate(mrng2).value;
-          expect(g1).toEqual(g2);
-          return true;
-        })
-      ));
-    it('Should generate words by default', () =>
-      fc.assert(
-        fc.property(fc.integer(), fc.integer(1, 100), (seed, num) => {
-          const mrng = new Random(prand.xorshift128plus(seed));
-          const g = lorem({ maxCount: num }).generate(mrng).value;
-          expect(g).not.toContain('.');
-        })
-      ));
-    it('Should not generate commas for words', () =>
-      fc.assert(
-        fc.property(fc.integer(), fc.integer(1, 100), (seed, num) => {
-          const mrng = new Random(prand.xorshift128plus(seed));
-          const g = lorem({ maxCount: num }).generate(mrng).value;
-          expect(g).not.toContain(',');
-        })
-      ));
-    it('Should generate sentences ending by dot', () =>
-      fc.assert(
-        fc.property(fc.integer(), (seed) => {
-          const mrng = new Random(prand.xorshift128plus(seed));
-          const g = lorem({ mode: 'sentences' }).generate(mrng).value;
-          expect(g).toContain('.');
-          expect(g[g.length - 1]).toEqual('.');
-
-          // we remove the trailing dot at the end of the generated string
-          // we remove the leading space for sentences with index greater than 0
-          const sentences = g
-            .substr(0, g.length - 1)
-            .split('.')
-            .map((s, i) => (i === 0 ? s : s.substring(1)));
-          for (const s of sentences) {
-            expect(s).not.toEqual('');
-            expect(s).toMatch(/^[A-Z](, | )?([a-z]+(, | )?)*$/);
-          }
-          expect(sentences.length).toBeGreaterThanOrEqual(1);
-          expect(sentences.length).toBeLessThanOrEqual(5);
-        })
-      ));
-
-    describe('Still support non recommended signatures', () => {
-      it('Should support fc.lorem(maxWordsCount)', () => {
-        fc.assert(
-          fc.property(fc.integer(), fc.integer(1, 100), (seed, maxWordsCount) => {
-            const refArbitrary = lorem({ maxCount: maxWordsCount });
-            const nonRecommendedArbitrary = lorem(maxWordsCount);
-            expect(generateOneValue(seed, nonRecommendedArbitrary)).toEqual(generateOneValue(seed, refArbitrary));
-          })
-        );
-      });
-      it('Should support fc.lorem(maxWordsCount, sentencesMode)', () => {
-        fc.assert(
-          fc.property(fc.integer(), fc.integer(1, 100), fc.boolean(), (seed, maxWordsCount, sentencesMode) => {
-            const refArbitrary = lorem({ maxCount: maxWordsCount, mode: sentencesMode ? 'sentences' : 'words' });
-            const nonRecommendedArbitrary = lorem(maxWordsCount, sentencesMode);
-            expect(generateOneValue(seed, nonRecommendedArbitrary)).toEqual(generateOneValue(seed, refArbitrary));
-          })
-        );
-      });
-    });
-  });
-});
diff --git a/test/unit/arbitrary/_internals/mappers/WordsToLorem.spec.ts b/test/unit/arbitrary/_internals/mappers/WordsToLorem.spec.ts
new file mode 100644
index 00000000..045cc273
--- /dev/null
+++ b/test/unit/arbitrary/_internals/mappers/WordsToLorem.spec.ts
@@ -0,0 +1,249 @@
+import fc from '../../../../../lib/fast-check';
+import {
+  sentencesToParagraphMapper,
+  sentencesToParagraphUnmapper,
+  wordsToJoinedStringMapper,
+  wordsToJoinedStringUnmapperFor,
+  wordsToSentenceMapper,
+  wordsToSentenceUnmapperFor,
+} from '../../../../../src/arbitrary/_internals/mappers/WordsToLorem';
+import { fakeNextArbitrary } from '../../../check/arbitrary/generic/NextArbitraryHelpers';
+
+const wordArbitraryWithoutComma = fc.stringOf(
+  fc.nat({ max: 25 }).map((v) => String.fromCodePoint(97 + v)),
+  { minLength: 1 }
+);
+const wordArbitrary = fc
+  .tuple(wordArbitraryWithoutComma, fc.boolean())
+  .map(([word, hasComma]) => (hasComma ? `${word},` : word));
+const wordsArrayArbitrary = fc.set(wordArbitrary, {
+  minLength: 1,
+  compare: (a: string, b: string) => a === b || `${a},` === b || a === `${b},`,
+});
+
+describe('wordsToJoinedStringMapper', () => {
+  it.each`
+    words                                 | expectedOutput           | behaviour
+    ${['il', 'était', 'une', 'fois']}     | ${'il était une fois'}   | ${'join using space'}
+    ${['demain,', 'il', 'fera', 'beau']}  | ${'demain il fera beau'} | ${'trim trailing commas on each word'}
+    ${['demain', 'il,', 'fera', 'beau,']} | ${'demain il fera beau'} | ${'trim trailing commas on each word'}
+  `('should map $words into $expectedOutput ($behaviour)', ({ words, expectedOutput }) => {
+    // Arrange / Act
+    const out = wordsToJoinedStringMapper(words);
+
+    // Assert
+    expect(out).toBe(expectedOutput);
+  });
+});
+
+describe('wordsToJoinedStringUnmapperFor', () => {
+  it('should unmap string made of words strictly coming from the source', () => {
+    // Arrange
+    const { instance: wordsArbitrary, canGenerate } = fakeNextArbitrary<string>();
+    canGenerate.mockImplementation(
+      (value) => typeof value === 'string' && ['hello', 'world', 'winter', 'summer'].includes(value)
+    );
+
+    // Act
+    const unmapper = wordsToJoinedStringUnmapperFor(wordsArbitrary);
+    const unmappedValue = unmapper('hello hello winter world');
+
+    // Assert
+    expect(unmappedValue).toEqual(['hello', 'hello', 'winter', 'world']);
+  });
+
+  it('should unmap string made of words with some having trimmed commas', () => {
+    // Arrange
+    const { instance, canGenerate } = fakeNextArbitrary<string>();
+    canGenerate.mockImplementation(
+      (value) => typeof value === 'string' && ['hello,', 'world,', 'winter', 'summer'].includes(value)
+    );
+
+    // Act
+    const unmapper = wordsToJoinedStringUnmapperFor(instance);
+    const unmappedValue = unmapper('hello hello winter world');
+
+    // Assert
+    expect(unmappedValue).toEqual(['hello,', 'hello,', 'winter', 'world,']);
+  });
+
+  it('should reject strings containing unknown words', () => {
+    // Arrange
+    const { instance, canGenerate } = fakeNextArbitrary<string>();
+    canGenerate.mockImplementation(
+      (value) => typeof value === 'string' && ['hello,', 'world,', 'spring', 'summer'].includes(value)
+    );
+
+    // Act / Assert
+    const unmapper = wordsToJoinedStringUnmapperFor(instance);
+    expect(() => unmapper('hello hello winter world')).toThrowError();
+  });
+
+  it('should unmap any string coming from the mapper', () =>
+    fc.assert(
+      fc.property(wordsArrayArbitrary, (words) => {
+        // Arrange
+        const { instance, canGenerate } = fakeNextArbitrary<string>();
+        canGenerate.mockImplementation((value) => typeof value === 'string' && words.includes(value));
+
+        // Act
+        const mapped = wordsToJoinedStringMapper(words);
+        const unmapper = wordsToJoinedStringUnmapperFor(instance);
+        const unmappedValue = unmapper(mapped);
+
+        // Assert
+        expect(unmappedValue).toEqual(words);
+      })
+    ));
+});
+
+describe('wordsToSentenceMapper', () => {
+  it.each`
+    words                                 | expectedOutput             | behaviour
+    ${['il', 'était', 'une', 'fois']}     | ${'Il était une fois.'}    | ${'join using space'}
+    ${['demain,', 'il', 'fera', 'beau']}  | ${'Demain, il fera beau.'} | ${'trim trailing commas only on last word'}
+    ${['demain', 'il,', 'fera', 'beau,']} | ${'Demain il, fera beau.'} | ${'trim trailing commas only on last word'}
+    ${['demain']}                         | ${'Demain.'}               | ${'one word sentence'}
+    ${['demain,']}                        | ${'Demain.'}               | ${'one word comma-ending sentence'}
+  `('should map $words into $expectedOutput ($behaviour)', ({ words, expectedOutput }) => {
+    // Arrange / Act
+    const out = wordsToSentenceMapper(words);
+
+    // Assert
+    expect(out).toBe(expectedOutput);
+  });
+});
+
+describe('wordsToSentenceUnmapperFor', () => {
+  it('should unmap string made of words strictly coming from the source', () => {
+    // Arrange
+    const { instance: wordsArbitrary, canGenerate } = fakeNextArbitrary<string>();
+    canGenerate.mockImplementation(
+      (value) => typeof value === 'string' && ['hello', 'world', 'winter', 'summer'].includes(value)
+    );
+
+    // Act
+    const unmapper = wordsToSentenceUnmapperFor(wordsArbitrary);
+    const unmappedValue = unmapper('Hello hello winter world.');
+
+    // Assert
+    expect(unmappedValue).toEqual(['hello', 'hello', 'winter', 'world']);
+  });
+
+  it('should unmap string made of words with last one having trimmed comma', () => {
+    // Arrange
+    const { instance, canGenerate } = fakeNextArbitrary<string>();
+    canGenerate.mockImplementation(
+      (value) => typeof value === 'string' && ['hello,', 'world,', 'winter', 'summer'].includes(value)
+    );
+
+    // Act
+    const unmapper = wordsToSentenceUnmapperFor(instance);
+    const unmappedValue = unmapper('Hello, hello, winter world.');
+
+    // Assert
+    expect(unmappedValue).toEqual(['hello,', 'hello,', 'winter', 'world,']);
+  });
+
+  it('should reject strings containing unknown words', () => {
+    // Arrange
+    const { instance, canGenerate } = fakeNextArbitrary<string>();
+    canGenerate.mockImplementation(
+      (value) => typeof value === 'string' && ['hello', 'world,', 'spring', 'summer'].includes(value)
+    );
+
+    // Act / Assert
+    const unmapper = wordsToSentenceUnmapperFor(instance);
+    expect(() => unmapper('Hello hello spring world.')).not.toThrowError();
+    expect(() => unmapper('Hello hello winter world.')).toThrowError();
+  });
+
+  it('should reject strings not starting by a capital leter', () => {
+    // Arrange
+    const { instance, canGenerate } = fakeNextArbitrary<string>();
+    canGenerate.mockImplementation(
+      (value) => typeof value === 'string' && ['hello', 'world,', 'winter', 'summer'].includes(value)
+    );
+
+    // Act / Assert
+    const unmapper = wordsToSentenceUnmapperFor(instance);
+    expect(() => unmapper('Hello hello winter world.')).not.toThrowError();
+    expect(() => unmapper('hello hello winter world.')).toThrowError();
+  });
+
+  it('should reject strings not ending by a point', () => {
+    // Arrange
+    const { instance, canGenerate } = fakeNextArbitrary<string>();
+    canGenerate.mockImplementation(
+      (value) => typeof value === 'string' && ['hello', 'world,', 'winter', 'summer'].includes(value)
+    );
+
+    // Act / Assert
+    const unmapper = wordsToSentenceUnmapperFor(instance);
+    expect(() => unmapper('Hello hello winter world.')).not.toThrowError();
+    expect(() => unmapper('Hello hello winter world')).toThrowError();
+  });
+
+  it('should reject strings with last word ending by a comma followed by point', () => {
+    // Arrange
+    const { instance, canGenerate } = fakeNextArbitrary<string>();
+    canGenerate.mockImplementation(
+      (value) => typeof value === 'string' && ['hello', 'world,', 'winter', 'summer'].includes(value)
+    );
+
+    // Act / Assert
+    const unmapper = wordsToSentenceUnmapperFor(instance);
+    expect(() => unmapper('Hello hello winter world.')).not.toThrowError();
+    expect(() => unmapper('Hello hello winter world,.')).toThrowError();
+  });
+
+  it("should reject strings if one of first words do not includes it's expected comma", () => {
+    // Arrange
+    const { instance, canGenerate } = fakeNextArbitrary<string>();
+    canGenerate.mockImplementation(
+      (value) => typeof value === 'string' && ['hello', 'world,', 'winter,', 'summer'].includes(value)
+    );
+
+    // Act / Assert
+    const unmapper = wordsToSentenceUnmapperFor(instance);
+    expect(() => unmapper('Hello hello winter, world.')).not.toThrowError();
+    expect(() => unmapper('Hello hello winter world.')).toThrowError();
+  });
+
+  it('should unmap any string coming from the mapper', () =>
+    fc.assert(
+      fc.property(wordsArrayArbitrary, (words) => {
+        // Arrange
+        const { instance, canGenerate } = fakeNextArbitrary<string>();
+        canGenerate.mockImplementation((value) => typeof value === 'string' && words.includes(value));
+
+        // Act
+        const mapped = wordsToSentenceMapper(words);
+        const unmapper = wordsToSentenceUnmapperFor(instance);
+        const unmappedValue = unmapper(mapped);
+
+        // Assert
+        expect(unmappedValue).toEqual(words);
+      })
+    ));
+});
+
+describe('wordsToSentenceUnmapperFor', () => {
+  it('should unmap any string coming from the mapper', () =>
+    fc.assert(
+      fc.property(
+        fc.array(
+          wordsArrayArbitrary.map((words) => wordsToSentenceMapper(words)),
+          { minLength: 1 }
+        ),
+        (sentences) => {
+          // Arrange / Act
+          const mapped = sentencesToParagraphMapper(sentences);
+          const unmappedValue = sentencesToParagraphUnmapper(mapped);
+
+          // Assert
+          expect(unmappedValue).toEqual(sentences);
+        }
+      )
+    ));
+});
diff --git a/test/unit/arbitrary/lorem.spec.ts b/test/unit/arbitrary/lorem.spec.ts
new file mode 100644
index 00000000..8ddcce1b
--- /dev/null
+++ b/test/unit/arbitrary/lorem.spec.ts
@@ -0,0 +1,96 @@
+import fc from '../../../lib/fast-check';
+import { lorem, LoremConstraints } from '../../../src/arbitrary/lorem';
+import { convertToNext } from '../../../src/check/arbitrary/definition/Converters';
+import {
+  assertGenerateProducesCorrectValues,
+  assertGenerateProducesSameValueGivenSameSeed,
+  assertGenerateProducesValuesFlaggedAsCanGenerate,
+  assertShrinkProducesCorrectValues,
+  assertShrinkProducesSameValueWithoutInitialContext,
+  assertShrinkProducesValuesFlaggedAsCanGenerate,
+} from '../check/arbitrary/generic/NextArbitraryAssertions';
+
+describe('lorem', () => {
+  it('should reject any negative or zero maxCount whatever the mode', () =>
+    fc.assert(
+      fc.property(
+        fc.integer({ max: 0 }),
+        fc.constantFrom(...([undefined, 'words', 'sentences'] as const)),
+        (maxCount, mode) => {
+          // Arrange / Act / Assert
+          expect(() => lorem({ maxCount, mode })).toThrowError();
+        }
+      )
+    ));
+});
+
+describe('lorem (integration)', () => {
+  type Extra = LoremConstraints;
+  const extraParameters: fc.Arbitrary<Extra> = fc.record(
+    {
+      maxCount: fc.integer({ min: 1, max: 100 }),
+      mode: fc.constantFrom(...(['words', 'sentences'] as const)),
+    },
+    { requiredKeys: [] }
+  );
+
+  const isCorrect = (value: string, extra: Extra) => {
+    const maxCount = extra.maxCount !== undefined ? extra.maxCount : 5;
+    switch (extra.mode) {
+      case 'sentences': {
+        expect(value).toContain('.');
+        expect(value[value.length - 1]).toEqual('.');
+        const sentences = value
+          // we remove the trailing dot at the end of the generated string
+          .substr(0, value.length - 1)
+          .split('.')
+          // we remove the leading space for sentences with index greater than 0
+          .map((s, i) => (i === 0 ? s : s.substring(1)));
+        for (const s of sentences) {
+          expect(s).not.toEqual('');
+          expect(s).toMatch(/^[A-Z](, | )?([a-z]+(, | )?)*$/);
+        }
+        expect(sentences.length).toBeGreaterThanOrEqual(1);
+        expect(sentences.length).toBeLessThanOrEqual(maxCount);
+        break;
+      }
+      case 'words':
+      default:
+        expect(value).not.toContain('.');
+        expect(value).not.toContain(',');
+        expect(value.split(' ').length).toBeGreaterThanOrEqual(1);
+        expect(value.split(' ').length).toBeLessThanOrEqual(maxCount);
+        break;
+    }
+  };
+
+  const loremBuilder = (extra: Extra) => convertToNext(lorem(extra));
+
+  it('should generate the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(loremBuilder, { extraParameters });
+  });
+
+  it('should only generate correct values', () => {
+    assertGenerateProducesCorrectValues(loremBuilder, isCorrect, { extraParameters });
+  });
+
+  it('should recognize values that would have been generated using it during generate', () => {
+    assertGenerateProducesValuesFlaggedAsCanGenerate(loremBuilder, { extraParameters });
+  });
+
+  it('should shrink towards the same values given the same seed', () => {
+    assertGenerateProducesSameValueGivenSameSeed(loremBuilder, { extraParameters });
+  });
+
+  it('should be able to shrink without any context', () => {
+    assertShrinkProducesSameValueWithoutInitialContext(loremBuilder, { extraParameters });
+  });
+
+  it('should only shrink towards correct values', () => {
+    assertShrinkProducesCorrectValues(loremBuilder, isCorrect, { extraParameters });
+  });
+
+  it('should recognize values that would have been generated using it during shrink', () => {
+    assertShrinkProducesValuesFlaggedAsCanGenerate(loremBuilder, { extraParameters });
+  });
+});