diff --git a/documentation/1-Guides/Arbitraries.md b/documentation/1-Guides/Arbitraries.md index 119841b56ce..b15bf8eecd1 100644 --- a/documentation/1-Guides/Arbitraries.md +++ b/documentation/1-Guides/Arbitraries.md @@ -77,6 +77,7 @@ More specific strings: - `fc.webSegment()` Web URL path segment - `fc.webUrl()` Web URL following the specs specified by RFC 3986 and WHATWG URL Standard - `fc.emailAddress()` Email address following RFC 1123 and RFC 5322 +- `fc.mixedCase(stringArb: Arbitrary)` or `fc.mixedCase(stringArb: Arbitrary, constraints: MixedCaseConstraints)` Randomly switch the case of characters generated by `stringArb` ## Date (:Date) diff --git a/src/check/arbitrary/MixedCaseArbitrary.ts b/src/check/arbitrary/MixedCaseArbitrary.ts new file mode 100644 index 00000000000..b94d6912c2e --- /dev/null +++ b/src/check/arbitrary/MixedCaseArbitrary.ts @@ -0,0 +1,123 @@ +import { Random } from '../../random/generator/Random'; +import { Stream } from '../../stream/Stream'; +import { bigUintN } from './BigIntArbitrary'; +import { Arbitrary } from './definition/Arbitrary'; +import { Shrinkable } from './definition/Shrinkable'; + +export interface MixedCaseConstraints { + /** Transform a character to its upper and/or lower case version */ + toggleCase?: (rawChar: string) => string; +} + +/** @hidden */ +export function countToggledBits(n: bigint): number { + let count = 0; + while (n > BigInt(0)) { + if (n & BigInt(1)) ++count; + n >>= BigInt(1); + } + return count; +} + +/** @hidden */ +export function computeNextFlags(flags: bigint, nextSize: number): bigint { + // whenever possible we want to preserve the same number of toggled positions + // whenever possible we want to keep them at the same place + // flags: 1000101 -> 10011 or 11001 (second choice for the moment) + const allowedMask = (BigInt(1) << BigInt(nextSize)) - BigInt(1); + const preservedFlags = flags & allowedMask; + let numMissingFlags = countToggledBits(flags - preservedFlags); + let nFlags = preservedFlags; + for (let mask = BigInt(1); mask <= allowedMask && numMissingFlags !== 0; mask <<= BigInt(1)) { + if (!(nFlags & mask)) { + nFlags |= mask; + --numMissingFlags; + } + } + return nFlags; +} + +/** @hidden */ +class MixedCaseArbitrary extends Arbitrary { + constructor(private readonly stringArb: Arbitrary, private readonly toggleCase: (rawChar: string) => string) { + super(); + } + private computeTogglePositions(chars: string[]): number[] { + const positions: number[] = []; + for (let idx = 0; idx !== chars.length; ++idx) { + if (this.toggleCase(chars[idx]) !== chars[idx]) positions.push(idx); + } + return positions; + } + private wrapper( + rawCase: Shrinkable, + chars: string[], + togglePositions: number[], + flags: bigint + ): Shrinkable { + const newChars = chars.slice(); + for (let idx = 0, mask = BigInt(1); idx !== togglePositions.length; ++idx, mask <<= BigInt(1)) { + if (flags & mask) newChars[togglePositions[idx]] = this.toggleCase(newChars[togglePositions[idx]]); + } + return new Shrinkable(newChars.join(''), () => this.shrinkImpl(rawCase, chars, togglePositions, flags)); + } + private shrinkImpl( + rawCase: Shrinkable, + chars: string[], + togglePositions: number[], + flags: bigint + ): Stream> { + return rawCase + .shrink() + .map(s => { + const nChars = [...s.value_]; + const nTogglePositions = this.computeTogglePositions(nChars); + const nFlags = computeNextFlags(flags, nTogglePositions.length); + return this.wrapper(s, nChars, nTogglePositions, nFlags); + }) + .join( + bigUintN(togglePositions.length) + .shrinkableFor(flags) + .shrink() + .map(nFlags => { + return this.wrapper(new Shrinkable(rawCase.value), chars, togglePositions, nFlags.value_); + }) + ); + } + generate(mrng: Random): Shrinkable { + const rawCaseShrinkable = this.stringArb.generate(mrng); + + const chars = [...rawCaseShrinkable.value_]; // split into valid unicode (keeps surrogate pairs) + const togglePositions = this.computeTogglePositions(chars); + + const flagsArb = bigUintN(togglePositions.length); + const flags = flagsArb.generate(mrng).value_; // true => toggle the char, false => keep it as-is + + return this.wrapper(rawCaseShrinkable, chars, togglePositions, flags); + } +} + +/** @hidden */ +function defaultToggleCase(rawChar: string) { + const upper = rawChar.toUpperCase(); + if (upper !== rawChar) return upper; + return rawChar.toLowerCase(); +} + +/** + * Randomly switch the case of characters generated by `stringArb` (upper/lower) + * + * WARNING: + * Require bigint support. + * Under-the-hood the arbitrary relies on bigint to compute the flags that should be toggled or not. + * + * @param stringArb Arbitrary able to build string values + * @param constraints Constraints to be applied when computing upper/lower case version + */ +export function mixedCase(stringArb: Arbitrary, constraints?: MixedCaseConstraints): Arbitrary { + if (typeof BigInt === 'undefined') { + throw new Error(`mixedCase requires BigInt support`); + } + const toggleCase = (constraints && constraints.toggleCase) || defaultToggleCase; + return new MixedCaseArbitrary(stringArb, toggleCase); +} diff --git a/src/fast-check-default.ts b/src/fast-check-default.ts index 86f9b1ac90b..bb516546766 100644 --- a/src/fast-check-default.ts +++ b/src/fast-check-default.ts @@ -28,6 +28,7 @@ import { letrec } from './check/arbitrary/LetRecArbitrary'; import { lorem } from './check/arbitrary/LoremArbitrary'; import { mapToConstant } from './check/arbitrary/MapToConstantArbitrary'; import { memo, Memo } from './check/arbitrary/MemoArbitrary'; +import { mixedCase, MixedCaseConstraints } from './check/arbitrary/MixedCaseArbitrary'; import { anything, json, @@ -117,6 +118,7 @@ export { fullUnicode, hexa, base64, + mixedCase, string, asciiString, string16bits, @@ -184,6 +186,7 @@ export { ExecutionStatus, ExecutionTree, Memo, + MixedCaseConstraints, ObjectConstraints, Parameters, RecordConstraints, diff --git a/test/e2e/NoRegressionBigInt.spec.ts b/test/e2e/NoRegressionBigInt.spec.ts index 60fedb5bb06..91869503a41 100644 --- a/test/e2e/NoRegressionBigInt.spec.ts +++ b/test/e2e/NoRegressionBigInt.spec.ts @@ -35,4 +35,9 @@ describe(`NoRegression BigInt`, () => { it('bigUint', () => { expect(() => fc.assert(fc.property(fc.bigUint(), v => testFunc(v)), settings)).toThrowErrorMatchingSnapshot(); }); + it('mixedCase', () => { + expect(() => + fc.assert(fc.property(fc.mixedCase(fc.hexaString()), v => testFunc(v)), settings) + ).toThrowErrorMatchingSnapshot(); + }); }); diff --git a/test/e2e/__snapshots__/NoRegressionBigInt.spec.ts.snap b/test/e2e/__snapshots__/NoRegressionBigInt.spec.ts.snap index 7d5c57f96e7..567aaf84252 100644 --- a/test/e2e/__snapshots__/NoRegressionBigInt.spec.ts.snap +++ b/test/e2e/__snapshots__/NoRegressionBigInt.spec.ts.snap @@ -725,3 +725,45 @@ Execution summary: . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . √ [987n] . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . √ [989n]" `; + +exports[`NoRegression BigInt mixedCase 1`] = ` +"Property failed after 2 tests +{ seed: 42, path: \\"1:2:1:13:10\\", endOnFailure: true } +Counterexample: [\\"aa\\"] +Shrunk 4 time(s) +Got error: Property failed by returning false + +Execution summary: +√ [\\"a3A0\\"] +× [\\"53dAA379\\"] +. √ [\\"\\"] +. √ [\\"A379\\"] +. × [\\"dAA379\\"] +. . √ [\\"379\\"] +. . × [\\"AA379\\"] +. . . √ [\\"379\\"] +. . . √ [\\"A379\\"] +. . . √ [\\"0A379\\"] +. . . √ [\\"5A379\\"] +. . . √ [\\"8A379\\"] +. . . √ [\\"9A379\\"] +. . . √ [\\"A\\"] +. . . √ [\\"A79\\"] +. . . √ [\\"A379\\"] +. . . √ [\\"A0379\\"] +. . . √ [\\"A5379\\"] +. . . √ [\\"A8379\\"] +. . . √ [\\"A9379\\"] +. . . × [\\"AA\\"] +. . . . √ [\\"A\\"] +. . . . √ [\\"0A\\"] +. . . . √ [\\"5A\\"] +. . . . √ [\\"8A\\"] +. . . . √ [\\"9A\\"] +. . . . √ [\\"A\\"] +. . . . √ [\\"A0\\"] +. . . . √ [\\"A5\\"] +. . . . √ [\\"A8\\"] +. . . . √ [\\"A9\\"] +. . . . × [\\"aa\\"]" +`; diff --git a/test/unit/check/arbitrary/MixedCaseArbitrary.itest.spec.ts b/test/unit/check/arbitrary/MixedCaseArbitrary.itest.spec.ts new file mode 100644 index 00000000000..2b520e77478 --- /dev/null +++ b/test/unit/check/arbitrary/MixedCaseArbitrary.itest.spec.ts @@ -0,0 +1,25 @@ +import { nat } from '../../../../src/check/arbitrary/IntegerArbitrary'; +import { stringOf } from '../../../../src/check/arbitrary/StringArbitrary'; +import { mixedCase } from '../../../../src/check/arbitrary/MixedCaseArbitrary'; + +import * as genericHelper from './generic/GenericArbitraryHelper'; + +declare function BigInt(n: number | bigint | string): bigint; + +describe('MixedCaseArbitrary', () => { + if (typeof BigInt === 'undefined') { + it('no test', () => { + expect(true).toBe(true); + }); + return; + } + describe('mixedCase', () => { + const stringArb = stringOf(nat(3).map(id => ['0', '1', 'A', 'B'][id])); + genericHelper.isValidArbitrary(() => mixedCase(stringArb), { + isStrictlySmallerValue: (v1, v2) => { + return v1.length < v2.length || v1 < v2 /* '0' < 'A' < 'a' */; + }, + isValidValue: (g: string) => typeof g === 'string' && [...g].every(c => '01abAB'.includes(c)) + }); + }); +}); diff --git a/test/unit/check/arbitrary/MixedCaseArbitrary.utest.spec.ts b/test/unit/check/arbitrary/MixedCaseArbitrary.utest.spec.ts new file mode 100644 index 00000000000..e589780eab6 --- /dev/null +++ b/test/unit/check/arbitrary/MixedCaseArbitrary.utest.spec.ts @@ -0,0 +1,229 @@ +import * as fc from '../../../../lib/fast-check'; + +import { mixedCase, countToggledBits, computeNextFlags } from '../../../../src/check/arbitrary/MixedCaseArbitrary'; + +jest.mock('../../../../src/check/arbitrary/BigIntArbitrary'); +import * as BigIntArbitraryMock from '../../../../src/check/arbitrary/BigIntArbitrary'; +import * as stubRng from '../../stubs/generators'; +import { mockModule } from './generic/MockedModule'; +import { arbitraryFor } from './generic/ArbitraryBuilder'; + +const mrng = () => stubRng.mutable.nocall(); + +declare function BigInt(n: number | bigint | string): bigint; + +describe('MixedCaseArbitrary', () => { + if (typeof BigInt === 'undefined') { + it('no test', () => { + expect(true).toBe(true); + }); + return; + } + describe('mixedCase', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should not toggle any character if flags are null', () => { + // Arrange + const { bigUintN } = mockModule(BigIntArbitraryMock); + bigUintN.mockImplementationOnce(n => arbitraryFor([{ value: BigInt(0) }])); + const stringArb = arbitraryFor([{ value: 'azerty' }]); + + // Act + const arb = mixedCase(stringArb); + const { value_: s } = arb.generate(mrng()); + + // Assert + expect(s).toBe('azerty'); + expect(bigUintN).toHaveBeenCalledWith(6); + }); + it('should toggle characters according to flags', () => { + // Arrange + const { bigUintN } = mockModule(BigIntArbitraryMock); + bigUintN.mockImplementationOnce(n => arbitraryFor([{ value: BigInt(9) /* 001001 */ }])); + const stringArb = arbitraryFor([{ value: 'azerty' }]); + + // Act + const arb = mixedCase(stringArb); + const { value_: s } = arb.generate(mrng()); + + // Assert + expect(s).toBe('AzeRty'); + expect(bigUintN).toHaveBeenCalledWith(6); + }); + it('should toggle both lower and upper characters', () => { + // Arrange + const { bigUintN } = mockModule(BigIntArbitraryMock); + bigUintN.mockImplementationOnce(n => arbitraryFor([{ value: BigInt(9) /* 001001 */ }])); + const stringArb = arbitraryFor([{ value: 'azERty' }]); + + // Act + const arb = mixedCase(stringArb); + const { value_: s } = arb.generate(mrng()); + + // Assert + expect(s).toBe('AzErty'); + expect(bigUintN).toHaveBeenCalledWith(6); + }); + it('should not try to toggle characters that do not have lower/upper versions', () => { + // Arrange + const { bigUintN } = mockModule(BigIntArbitraryMock); + bigUintN.mockImplementationOnce(n => arbitraryFor([{ value: BigInt(0) }])); + const stringArb = arbitraryFor([{ value: 'az01ty' }]); // 01 upper version is the same + + // Act + const arb = mixedCase(stringArb); + const { value_: s } = arb.generate(mrng()); + + // Assert + expect(s).toBe('az01ty'); + expect(bigUintN).toHaveBeenCalledWith(4); + }); + it('should shrink by merging string and flags shrinkers', () => { + // Arrange + const { bigUintN } = mockModule(BigIntArbitraryMock); + bigUintN.mockImplementation(n => { + switch (n) { + case 6: // azerty + return arbitraryFor([ + { + value: BigInt(0b100100), + shrinks: [ + { value: BigInt(0b000100), shrinks: [{ value: BigInt(0b100000) }] }, + { value: BigInt(0b000000) } + ] + } + ]); + case 3: // aze + return arbitraryFor([{ value: BigInt(0b010), shrinks: [{ value: BigInt(0b000) }] }]); + case 4: // azer + return arbitraryFor([ + { value: BigInt(0b1011), shrinks: [{ value: BigInt(0b1111) }, { value: BigInt(0b1000) }] }, + { value: BigInt(0b0101), shrinks: [{ value: BigInt(0b0011) }, { value: BigInt(0b0010) }] } + ]); + case 0: // + default: + return arbitraryFor([{ value: BigInt(0) }]); + } + }); + const stringArb = arbitraryFor([ + { + value: 'azerty', + shrinks: [ + { value: 'aze', shrinks: [{ value: '' }] }, + { value: 'azer', shrinks: [{ value: 'az' }, { value: '' }] } + ] + } + ]); + + // Act + const arb = mixedCase(stringArb); + const s0 = arb.generate(mrng()); + const level0 = s0.value_; + const level1 = [...s0.shrink().map(s => s.value_)]; + const s1a = s0.shrink().getNthOrLast(1)!; + const level2a = [...s1a.shrink().map(s => s.value_)]; + const s1b = s0.shrink().getNthOrLast(2)!; + const level2b = [...s1b.shrink().map(s => s.value_)]; + + // Assert + expect(level0).toEqual('azErtY' /*azerty + 100100*/); + expect(level1).toEqual([ + 'AzE' /*aze + 101*/, + 'AzEr' /*azer + 0101*/, // <-- string shrinker for 'azerty' + 'azErty' /*azerty + 000100*/, + 'azerty' /*azerty + 000000*/ // <-- bigint shrinker for bigUintN(6)[0b100100] + ]); + expect(level2a).toEqual([ + 'AZ' /*az + 11*/, + '' /* + */, // <-- string shrinker for 'azer' + 'AZer' /*azer + 0011*/, + 'aZer' /*azer + 0010*/ // <-- bigint shrinker for bigUintN(4)[0b0101] + ]); + expect(level2b).toEqual([ + // 'azE' /*aze + 100*/, + // 'azEr' /*azer + 0100*/, // <-- string shrinker for 'azerty' (removed) + 'azertY' /*azerty + 100000*/ // <-- bigint shrinker for bigUintN(6)[0b000100] + ]); + }); + it('should use toggle function when provided to check what can be toggled or not', () => { + // Arrange + const { bigUintN } = mockModule(BigIntArbitraryMock); + bigUintN.mockImplementationOnce(n => arbitraryFor([{ value: BigInt(63) /* 111111 */ }])); + const stringArb = arbitraryFor([{ value: 'azerty' }]); + const customToggle = jest.fn(); + customToggle.mockImplementation((c: string) => { + if (c === 'a' || c === 't') return ''; + else return c; + }); + + // Act + const arb = mixedCase(stringArb, { toggleCase: customToggle }); + const { value_: s } = arb.generate(mrng()); + + // Assert + expect(s).toBe('zery'); + expect(bigUintN).toHaveBeenCalledWith(2); + }); + }); + describe('countToggledBits', () => { + it('should properly count when zero bits are toggled', () => { + expect(countToggledBits(BigInt(0))).toBe(0); + }); + it('should properly count when all bits are toggled', () => { + expect(countToggledBits(BigInt(0xffffffff))).toBe(32); + }); + it('should properly count when part of the bits are toggled', () => { + expect(countToggledBits(BigInt(7456))).toBe(5); + }); + }); + describe('computeNextFlags', () => { + it('should keep the same flags if size has not changed', () => { + const flags = BigInt(243); // 11110011 -> 11110011 + expect(computeNextFlags(flags, 8)).toBe(flags); + }); + it('should keep the same flags if number of starting zeros is enough', () => { + const flags = BigInt(121); // 01111001 -> 1111001 + expect(computeNextFlags(flags, 7)).toBe(flags); + }); + it('should keep the same flags if size is longer', () => { + const flags = BigInt(242); // 11110010 -> 011110010 + expect(computeNextFlags(flags, 9)).toBe(flags); + }); + it('should keep the same number of toggled flags for flags not existing anymore', () => { + const flags = BigInt(147); // 10010011 + const expectedFlags = BigInt(23); // 0010111 - start by filling by the right + expect(computeNextFlags(flags, 7)).toBe(expectedFlags); + }); + it('should properly deal with cases where flags have to be removed', () => { + const flags = BigInt(243); // 11110011 + const expectedFlags = BigInt(3); // 11 + expect(computeNextFlags(flags, 2)).toBe(expectedFlags); + }); + it('should preserve the same number of flags', () => + fc.assert( + fc.property(fc.bigUint(), fc.nat(100), (flags, offset) => { + const sourceToggled = countToggledBits(flags); + const nextSize = sourceToggled + offset; // anything >= sourceToggled + const nextFlags = computeNextFlags(flags, nextSize); + expect(countToggledBits(nextFlags)).toBe(sourceToggled); + }) + )); + it('should preserve the position of existing flags', () => + fc.assert( + fc.property(fc.bigUint(), fc.integer(1, 100), (flags, nextSize) => { + const nextFlags = computeNextFlags(flags, nextSize); + for (let idx = 0, mask = BigInt(1); idx !== nextSize; ++idx, mask <<= BigInt(1)) { + if (flags & mask) expect(!!(nextFlags & mask)).toBe(true); + } + }) + )); + it('should not return flags larger than the asked size', () => + fc.assert( + fc.property(fc.bigUint(), fc.nat(100), (flags, nextSize) => { + const nextFlags = computeNextFlags(flags, nextSize); + expect(nextFlags < BigInt(1) << BigInt(nextSize)).toBe(true); + }) + )); + }); +});