From e3e87366158c4d2a3aaebe65056372c416836d1b Mon Sep 17 00:00:00 2001 From: Tatsunori Uchino Date: Sun, 20 Oct 2024 10:57:43 +0900 Subject: [PATCH] Implement code points validator --- .../actions/codePoints/codePoints.test-d.ts | 50 ++++++ .../src/actions/codePoints/codePoints.test.ts | 121 ++++++++++++++ library/src/actions/codePoints/codePoints.ts | 128 ++++++++++++++ library/src/actions/codePoints/index.ts | 1 + library/src/actions/index.ts | 3 + library/src/actions/maxCodePoints/index.ts | 1 + .../maxCodePoints/maxCodePoints.test-d.ts | 50 ++++++ .../maxCodePoints/maxCodePoints.test.ts | 150 +++++++++++++++++ .../actions/maxCodePoints/maxCodePoints.ts | 134 +++++++++++++++ library/src/actions/minCodePoints/index.ts | 1 + .../minCodePoints/minCodePoints.test-d.ts | 50 ++++++ .../minCodePoints/minCodePoints.test.ts | 157 ++++++++++++++++++ .../actions/minCodePoints/minCodePoints.ts | 134 +++++++++++++++ .../_getCodePointCount.test.ts | 16 ++ .../_getCodePointCountCount.ts | 23 +++ library/src/utils/_getCodePointCount/index.ts | 1 + library/src/utils/index.ts | 1 + 17 files changed, 1021 insertions(+) create mode 100644 library/src/actions/codePoints/codePoints.test-d.ts create mode 100644 library/src/actions/codePoints/codePoints.test.ts create mode 100644 library/src/actions/codePoints/codePoints.ts create mode 100644 library/src/actions/codePoints/index.ts create mode 100644 library/src/actions/maxCodePoints/index.ts create mode 100644 library/src/actions/maxCodePoints/maxCodePoints.test-d.ts create mode 100644 library/src/actions/maxCodePoints/maxCodePoints.test.ts create mode 100644 library/src/actions/maxCodePoints/maxCodePoints.ts create mode 100644 library/src/actions/minCodePoints/index.ts create mode 100644 library/src/actions/minCodePoints/minCodePoints.test-d.ts create mode 100644 library/src/actions/minCodePoints/minCodePoints.test.ts create mode 100644 library/src/actions/minCodePoints/minCodePoints.ts create mode 100644 library/src/utils/_getCodePointCount/_getCodePointCount.test.ts create mode 100644 library/src/utils/_getCodePointCount/_getCodePointCountCount.ts create mode 100644 library/src/utils/_getCodePointCount/index.ts diff --git a/library/src/actions/codePoints/codePoints.test-d.ts b/library/src/actions/codePoints/codePoints.test-d.ts new file mode 100644 index 000000000..819ac0c90 --- /dev/null +++ b/library/src/actions/codePoints/codePoints.test-d.ts @@ -0,0 +1,50 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; +import { + codePoints, + type CodePointsAction, + type CodePointsIssue, +} from './codePoints.ts'; + +describe('codePoints', () => { + describe('should return action object', () => { + test('with undefined message', () => { + type Action = CodePointsAction; + expectTypeOf(codePoints(10)).toEqualTypeOf(); + expectTypeOf( + codePoints(10, undefined) + ).toEqualTypeOf(); + }); + + test('with string message', () => { + expectTypeOf( + codePoints(10, 'message') + ).toEqualTypeOf>(); + }); + + test('with function message', () => { + expectTypeOf( + codePoints string>(10, () => 'message') + ).toEqualTypeOf string>>(); + }); + }); + + describe('should infer correct types', () => { + type Input = 'example string'; + type Action = CodePointsAction; + + test('of input', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of output', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of issue', () => { + expectTypeOf>().toEqualTypeOf< + CodePointsIssue + >(); + }); + }); +}); diff --git a/library/src/actions/codePoints/codePoints.test.ts b/library/src/actions/codePoints/codePoints.test.ts new file mode 100644 index 000000000..9360d7f32 --- /dev/null +++ b/library/src/actions/codePoints/codePoints.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'vitest'; +import type { StringIssue } from '../../schemas/index.ts'; +import { _getCodePointCount } from '../../utils/index.ts'; +import { expectActionIssue, expectNoActionIssue } from '../../vitest/index.ts'; +import { + codePoints, + type CodePointsAction, + type CodePointsIssue, +} from './codePoints.ts'; + +describe('graphemes', () => { + describe('should return action object', () => { + const baseAction: Omit, 'message'> = { + kind: 'validation', + type: 'code_points', + reference: codePoints, + expects: '5', + requirement: 5, + async: false, + '~validate': expect.any(Function), + }; + + test('with undefined message', () => { + const action: CodePointsAction = { + ...baseAction, + message: undefined, + }; + expect(codePoints(5)).toStrictEqual(action); + expect(codePoints(5, undefined)).toStrictEqual(action); + }); + + test('with string message', () => { + expect(codePoints(5, 'message')).toStrictEqual({ + ...baseAction, + message: 'message', + } satisfies CodePointsAction); + }); + + test('with function message', () => { + const message = () => 'message'; + expect(codePoints(5, message)).toStrictEqual({ + ...baseAction, + message, + } satisfies CodePointsAction); + }); + }); + + describe('should return dataset without issues', () => { + const action = codePoints(5); + + test('for untyped inputs', () => { + const issues: [StringIssue] = [ + { + kind: 'schema', + type: 'string', + input: null, + expected: 'string', + received: 'null', + message: 'message', + }, + ]; + expect( + action['~validate']({ typed: false, value: null, issues }, {}) + ).toStrictEqual({ + typed: false, + value: null, + issues, + }); + }); + + test('for valid strings', () => { + expectNoActionIssue(action, ['12345', '12 45', '1234 ', 'hello']); + }); + + test('for valid emoji', () => { + expectNoActionIssue(action, ['ðŸ‘ĻðŸ―â€ðŸ‘ĐðŸ―', 'ðŸ˜ķ‍ðŸŒŦïļðŸ˜€', 'ðŸ˜ĄðŸ‘ðŸ˜ðŸ˜‚ðŸ˜€', '0ïļâƒĢ㊙ïļ']); + }); + + test('for valid non-latin', () => { + expectNoActionIssue(action, ['あ𛀙よろし', 'ð Ū·é‡ŽåŪķでðĐļ―', 'č‘›ó „€åŸŽåļ‚']); + }); + }); + + describe('should return dataset with issues', () => { + const action = codePoints(5, 'message'); + const baseIssue: Omit, 'input' | 'received'> = { + kind: 'validation', + type: 'code_points', + expected: '5', + message: 'message', + requirement: 5, + }; + + test('for invalid strings', () => { + expectActionIssue( + action, + baseIssue, + ['', ' ', '1', '1234', '123 ', '123456', '12 456', '123456789'], + (value) => `${_getCodePointCount(value)}` + ); + }); + + test('for invalid emoji', () => { + expectActionIssue( + action, + baseIssue, + ['😀👋🏞ðŸ§ĐðŸ‘ĐðŸŧ‍ðŸŦðŸŦĨ', '㊙ïļãŠ™ïļ0ïļâƒĢ1ïļâƒĢ2ïļâƒĢ'], + (value) => `${_getCodePointCount(value)}` + ); + }); + + test('for invalid non-latin', () => { + expectActionIssue( + action, + baseIssue, + ['įŦˆé–€įĶ°ó „€čą†å­', 'č‘›ó „€åŸŽåļ‚'], + (value) => `${_getCodePointCount(value)}` + ); + }); + }); +}); diff --git a/library/src/actions/codePoints/codePoints.ts b/library/src/actions/codePoints/codePoints.ts new file mode 100644 index 000000000..cb34c8f54 --- /dev/null +++ b/library/src/actions/codePoints/codePoints.ts @@ -0,0 +1,128 @@ +import type { + BaseIssue, + BaseValidation, + ErrorMessage, +} from '../../types/index.ts'; +import { _addIssue, _getCodePointCount } from '../../utils/index.ts'; + +/** + * CodePoints issue type. + */ +export interface CodePointsIssue< + TInput extends string, + TRequirement extends number, +> extends BaseIssue { + /** + * The issue kind. + */ + readonly kind: 'validation'; + /** + * The issue type. + */ + readonly type: 'code_points'; + /** + * The expected property. + */ + readonly expected: `${TRequirement}`; + /** + * The received property. + */ + readonly received: `${number}`; + /** + * The required codePoints. + */ + readonly requirement: TRequirement; +} + +/** + * CodePoints action type. + */ +export interface CodePointsAction< + TInput extends string, + TRequirement extends number, + TMessage extends + | ErrorMessage> + | undefined, +> extends BaseValidation> { + /** + * The action type. + */ + readonly type: 'code_points'; + /** + * The action reference. + */ + readonly reference: typeof codePoints; + /** + * The expected property. + */ + readonly expects: `${TRequirement}`; + /** + * The required code points. + */ + readonly requirement: TRequirement; + /** + * The error message. + */ + readonly message: TMessage; +} + +/** + * Creates a code points validation action. + * + * @param requirement The required code points. + * + * @returns A code points action. + */ +export function codePoints< + TInput extends string, + const TRequirement extends number, +>(requirement: TRequirement): CodePointsAction; + +/** + * Creates a code points validation action. + * + * @param requirement The required code points. + * @param message The error message. + * + * @returns A code points action. + */ +export function codePoints< + TInput extends string, + const TRequirement extends number, + const TMessage extends + | ErrorMessage> + | undefined, +>( + requirement: TRequirement, + message: TMessage +): CodePointsAction; + +export function codePoints( + requirement: number, + message?: ErrorMessage> +): CodePointsAction< + string, + number, + ErrorMessage> | undefined +> { + return { + kind: 'validation', + type: 'code_points', + reference: codePoints, + async: false, + expects: `${requirement}`, + requirement, + message, + '~validate'(dataset, config) { + if (dataset.typed) { + const count = _getCodePointCount(dataset.value); + if (count !== this.requirement) { + _addIssue(this, 'codePoints', dataset, config, { + received: `${count}`, + }); + } + } + return dataset; + }, + }; +} diff --git a/library/src/actions/codePoints/index.ts b/library/src/actions/codePoints/index.ts new file mode 100644 index 000000000..19100e9c5 --- /dev/null +++ b/library/src/actions/codePoints/index.ts @@ -0,0 +1 @@ +export * from './codePoints.ts'; diff --git a/library/src/actions/index.ts b/library/src/actions/index.ts index 0c37dd5ca..4b6db015b 100644 --- a/library/src/actions/index.ts +++ b/library/src/actions/index.ts @@ -6,6 +6,7 @@ export * from './brand/index.ts'; export * from './bytes/index.ts'; export * from './check/index.ts'; export * from './checkItems/index.ts'; +export * from './codePoints/index.ts'; export * from './creditCard/index.ts'; export * from './cuid2/index.ts'; export * from './decimal/index.ts'; @@ -42,6 +43,7 @@ export * from './mac48/index.ts'; export * from './mac64/index.ts'; export * from './mapItems/index.ts'; export * from './maxBytes/index.ts'; +export * from './maxCodePoints/index.ts'; export * from './maxGraphemes/index.ts'; export * from './maxLength/index.ts'; export * from './maxSize/index.ts'; @@ -50,6 +52,7 @@ export * from './maxWords/index.ts'; export * from './metadata/index.ts'; export * from './mimeType/index.ts'; export * from './minBytes/index.ts'; +export * from './minCodePoints/index.ts'; export * from './minGraphemes/index.ts'; export * from './minLength/index.ts'; export * from './minSize/index.ts'; diff --git a/library/src/actions/maxCodePoints/index.ts b/library/src/actions/maxCodePoints/index.ts new file mode 100644 index 000000000..224e1e57a --- /dev/null +++ b/library/src/actions/maxCodePoints/index.ts @@ -0,0 +1 @@ +export * from './maxCodePoints.ts'; diff --git a/library/src/actions/maxCodePoints/maxCodePoints.test-d.ts b/library/src/actions/maxCodePoints/maxCodePoints.test-d.ts new file mode 100644 index 000000000..ebf02cc6f --- /dev/null +++ b/library/src/actions/maxCodePoints/maxCodePoints.test-d.ts @@ -0,0 +1,50 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; +import { + maxCodePoints, + type MaxCodePointsAction, + type MaxCodePointsIssue, +} from './maxCodePoints.ts'; + +describe('maxCodePoints', () => { + describe('should return action object', () => { + test('with undefined message', () => { + type Action = MaxCodePointsAction; + expectTypeOf(maxCodePoints(10)).toEqualTypeOf(); + expectTypeOf( + maxCodePoints(10, undefined) + ).toEqualTypeOf(); + }); + + test('with string message', () => { + expectTypeOf( + maxCodePoints(10, 'message') + ).toEqualTypeOf>(); + }); + + test('with function message', () => { + expectTypeOf( + maxCodePoints string>(10, () => 'message') + ).toEqualTypeOf string>>(); + }); + }); + + describe('should infer correct types', () => { + type Input = 'example string'; + type Action = MaxCodePointsAction; + + test('of input', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of output', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of issue', () => { + expectTypeOf>().toEqualTypeOf< + MaxCodePointsIssue + >(); + }); + }); +}); diff --git a/library/src/actions/maxCodePoints/maxCodePoints.test.ts b/library/src/actions/maxCodePoints/maxCodePoints.test.ts new file mode 100644 index 000000000..cf5363abb --- /dev/null +++ b/library/src/actions/maxCodePoints/maxCodePoints.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from 'vitest'; +import type { StringIssue } from '../../schemas/index.ts'; +import { _getCodePointCount } from '../../utils/index.ts'; +import { expectActionIssue, expectNoActionIssue } from '../../vitest/index.ts'; +import { + maxCodePoints, + type MaxCodePointsAction, + type MaxCodePointsIssue, +} from './maxCodePoints.ts'; + +describe('maxCodePoints', () => { + describe('should return action object', () => { + const baseAction: Omit, 'message'> = { + kind: 'validation', + type: 'max_code_points', + reference: maxCodePoints, + expects: '<=5', + requirement: 5, + async: false, + '~validate': expect.any(Function), + }; + + test('with undefined message', () => { + const action: MaxCodePointsAction = { + ...baseAction, + message: undefined, + }; + expect(maxCodePoints(5)).toStrictEqual(action); + expect(maxCodePoints(5, undefined)).toStrictEqual(action); + }); + + test('with string message', () => { + expect(maxCodePoints(5, 'message')).toStrictEqual({ + ...baseAction, + message: 'message', + } satisfies MaxCodePointsAction); + }); + + test('with function message', () => { + const message = () => 'message'; + expect(maxCodePoints(5, message)).toStrictEqual({ + ...baseAction, + message, + } satisfies MaxCodePointsAction); + }); + }); + + describe('should return dataset without issues', () => { + const action = maxCodePoints(5); + + test('for untyped inputs', () => { + const issues: [StringIssue] = [ + { + kind: 'schema', + type: 'string', + input: null, + expected: 'string', + received: 'null', + message: 'message', + }, + ]; + expect( + action['~validate']({ typed: false, value: null, issues }, {}) + ).toStrictEqual({ + typed: false, + value: null, + issues, + }); + }); + + test('for valid strings', () => { + expectNoActionIssue(action, ['', ' ', '1', 'foo', '12345', '12 45']); + }); + + test('for valid emoji', () => { + expectNoActionIssue(action, ['😀', '😀👋🏞ðŸ§Đ', 'ðŸ˜ķ‍ðŸŒŦïļðŸ‘', '1ïļâƒĢ', '1ïļâƒĢ㊙ïļ']); + }); + + test('for valid non-latin', () => { + expectNoActionIssue(action, [ + 'ð Ū·é‡ŽåŪķ', // billboard notation + 'ð Ū·į”°åĪŠéƒŽ', + 'ð Ū·é‡ŽåŪķでðĐļ―', + 'åĨˆč‰Ŋč‘›ó „€åŸŽ', + 'åĨˆč‰Ŋč‘›åŸŽåļ‚', + 'įŦˆé–€įĶ°čą†å­', + 'あ𛀙よろし', + // We rarely see the following notations in the wild today (some antique restaurants or shops) + 'åĪĐð›‚ąã‚šð›ƒ­', // åĪĐぷら (tempura) + '𛁟゙んð›€ļ゙', // だんご (ðŸĄ) + ]); + }); + }); + + describe('should return dataset with issues', () => { + const action = maxCodePoints(5, 'message'); + const baseIssue: Omit< + MaxCodePointsIssue, + 'input' | 'received' + > = { + kind: 'validation', + type: 'max_code_points', + expected: '<=5', + message: 'message', + requirement: 5, + }; + + test('for invalid strings', () => { + expectActionIssue( + action, + baseIssue, + ['123456', '12345 ', '123456789', 'foo bar baz'], + (value) => `${_getCodePointCount(value)}` + ); + }); + + test('for invalid emoji', () => { + expectActionIssue( + action, + baseIssue, + [ + '1ïļâƒĢ2ïļâƒĢ', + 'ðŸ˜ķ‍ðŸŒŦïļðŸ‘ðŸ‘', + '😀👋🏞ðŸ§ĐðŸ‘ĐðŸŧ‍ðŸŦ', + '😀👋🏞ðŸ§ĐðŸ‘ĐðŸŧ‍ðŸŦðŸŦĨ', + '😀👋🏞ðŸ§ĐðŸ‘ĐðŸŧ‍ðŸŦðŸŦĨðŸŦ ', + '😀👋🏞ðŸ§ĐðŸ‘ĐðŸŧ‍ðŸŦðŸŦĨðŸŦ ðŸ§‘‍ðŸ’ŧðŸ‘ŧðŸĨŽ', + ], + (value) => `${_getCodePointCount(value)}` + ); + }); + + test('for invalid non-latin', () => { + expectActionIssue( + action, + baseIssue, + [ + // The culprit is E+0100 + 'åĨˆč‰Ŋč‘›ó „€åŸŽåļ‚', + 'åĨˆč‰ŊįœŒč‘›åŸŽåļ‚', + 'åĨˆč‰ŊįœŒč‘›ó „€åŸŽåļ‚', + 'įŦˆé–€įĶ°ó „€čą†å­', + // ðŸĄ: 1 code point emoji & U+3099 consumes one more code points + '𛁟゙んð›€ļã‚™ðŸĄ', + ], + (value) => `${_getCodePointCount(value)}` + ); + }); + }); +}); diff --git a/library/src/actions/maxCodePoints/maxCodePoints.ts b/library/src/actions/maxCodePoints/maxCodePoints.ts new file mode 100644 index 000000000..c0fd2a4f9 --- /dev/null +++ b/library/src/actions/maxCodePoints/maxCodePoints.ts @@ -0,0 +1,134 @@ +import type { + BaseIssue, + BaseValidation, + ErrorMessage, +} from '../../types/index.ts'; +import { _addIssue, _getCodePointCount } from '../../utils/index.ts'; + +/** + * Max code points issue type. + */ +export interface MaxCodePointsIssue< + TInput extends string, + TRequirement extends number, +> extends BaseIssue { + /** + * The issue kind. + */ + readonly kind: 'validation'; + /** + * The issue type. + */ + readonly type: 'max_code_points'; + /** + * The expected property. + */ + readonly expected: `<=${TRequirement}`; + /** + * The received property. + */ + readonly received: `${number}`; + /** + * The maximum code points. + */ + readonly requirement: TRequirement; +} + +/** + * Max code points action type. + */ +export interface MaxCodePointsAction< + TInput extends string, + TRequirement extends number, + TMessage extends + | ErrorMessage> + | undefined, +> extends BaseValidation< + TInput, + TInput, + MaxCodePointsIssue + > { + /** + * The action type. + */ + readonly type: 'max_code_points'; + /** + * The action reference. + */ + readonly reference: typeof maxCodePoints; + /** + * The expected property. + */ + readonly expects: `<=${TRequirement}`; + /** + * The maximum code points. + */ + readonly requirement: TRequirement; + /** + * The error message. + */ + readonly message: TMessage; +} + +/** + * Creates a max code points validation action. + * + * @param requirement The maximum code points. + * + * @returns A max code points action. + */ +export function maxCodePoints< + TInput extends string, + const TRequirement extends number, +>( + requirement: TRequirement +): MaxCodePointsAction; + +/** + * Creates a max code points validation action. + * + * @param requirement The maximum code points. + * @param message The error message. + * + * @returns A max code points action. + */ +export function maxCodePoints< + TInput extends string, + const TRequirement extends number, + const TMessage extends + | ErrorMessage> + | undefined, +>( + requirement: TRequirement, + message: TMessage +): MaxCodePointsAction; + +export function maxCodePoints( + requirement: number, + message?: ErrorMessage> +): MaxCodePointsAction< + string, + number, + ErrorMessage> | undefined +> { + return { + kind: 'validation', + type: 'max_code_points', + reference: maxCodePoints, + async: false, + expects: `<=${requirement}`, + requirement, + message, + '~validate'(dataset, config) { + if (dataset.typed) { + const count = _getCodePointCount(dataset.value); + if (count > this.requirement) { + _addIssue(this, 'code_points', dataset, config, { + received: `${count}`, + }); + } + } + return dataset; + }, + }; +} diff --git a/library/src/actions/minCodePoints/index.ts b/library/src/actions/minCodePoints/index.ts new file mode 100644 index 000000000..81315d4a3 --- /dev/null +++ b/library/src/actions/minCodePoints/index.ts @@ -0,0 +1 @@ +export * from './minCodePoints.ts'; diff --git a/library/src/actions/minCodePoints/minCodePoints.test-d.ts b/library/src/actions/minCodePoints/minCodePoints.test-d.ts new file mode 100644 index 000000000..502c3fdba --- /dev/null +++ b/library/src/actions/minCodePoints/minCodePoints.test-d.ts @@ -0,0 +1,50 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts'; +import { + minCodePoints, + type MinCodePointsAction, + type MinCodePointsIssue, +} from './minCodePoints.ts'; + +describe('minCodePoints', () => { + describe('should return action object', () => { + test('with undefined message', () => { + type Action = MinCodePointsAction; + expectTypeOf(minCodePoints(10)).toEqualTypeOf(); + expectTypeOf( + minCodePoints(10, undefined) + ).toEqualTypeOf(); + }); + + test('with string message', () => { + expectTypeOf( + minCodePoints(10, 'message') + ).toEqualTypeOf>(); + }); + + test('with function message', () => { + expectTypeOf( + minCodePoints string>(10, () => 'message') + ).toEqualTypeOf string>>(); + }); + }); + + describe('should infer correct types', () => { + type Input = 'example string'; + type Action = MinCodePointsAction; + + test('of input', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of output', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test('of issue', () => { + expectTypeOf>().toEqualTypeOf< + MinCodePointsIssue + >(); + }); + }); +}); diff --git a/library/src/actions/minCodePoints/minCodePoints.test.ts b/library/src/actions/minCodePoints/minCodePoints.test.ts new file mode 100644 index 000000000..0ad992c4a --- /dev/null +++ b/library/src/actions/minCodePoints/minCodePoints.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from 'vitest'; +import type { StringIssue } from '../../schemas/index.ts'; +import { _getCodePointCount } from '../../utils/index.ts'; +import { expectActionIssue, expectNoActionIssue } from '../../vitest/index.ts'; +import { + minCodePoints, + type MinCodePointsAction, + type MinCodePointsIssue, +} from './minCodePoints.ts'; + +describe('minCodePoints', () => { + describe('should return action object', () => { + const baseAction: Omit, 'message'> = { + kind: 'validation', + type: 'min_code_points', + reference: minCodePoints, + expects: '>=5', + requirement: 5, + async: false, + '~validate': expect.any(Function), + }; + + test('with undefined message', () => { + const action: MinCodePointsAction = { + ...baseAction, + message: undefined, + }; + expect(minCodePoints(5)).toStrictEqual(action); + expect(minCodePoints(5, undefined)).toStrictEqual(action); + }); + + test('with string message', () => { + expect(minCodePoints(5, 'message')).toStrictEqual({ + ...baseAction, + message: 'message', + } satisfies MinCodePointsAction); + }); + + test('with function message', () => { + const message = () => 'message'; + expect(minCodePoints(5, message)).toStrictEqual({ + ...baseAction, + message, + } satisfies MinCodePointsAction); + }); + }); + + describe('should return dataset without issues', () => { + const action = minCodePoints(5); + + test('for untyped inputs', () => { + const issues: [StringIssue] = [ + { + kind: 'schema', + type: 'string', + input: null, + expected: 'string', + received: 'null', + message: 'message', + }, + ]; + expect( + action['~validate']({ typed: false, value: null, issues }, {}) + ).toStrictEqual({ + typed: false, + value: null, + issues, + }); + }); + + test('for valid strings', () => { + expectNoActionIssue(action, [ + '12345', + '1234 ', + '123456', + '123456789', + 'foo bar baz', + ]); + }); + + test('for valid emoji', () => { + expectNoActionIssue(action, [ + '1ïļâƒĢ㊙ïļ', + '1ïļâƒĢ2ïļâƒĢ', + 'ðŸ˜ķ‍ðŸŒŦïļðŸ‘', + 'ðŸ˜ķ‍ðŸŒŦïļðŸ‘ðŸ‘', + '😀👋🏞ðŸ§ĐðŸ‘ĐðŸŧ‍ðŸŦ', + '😀👋🏞ðŸ§ĐðŸ‘ĐðŸŧ‍ðŸŦðŸŦĨ', + '😀👋🏞ðŸ§ĐðŸ‘ĐðŸŧ‍ðŸŦðŸŦĨðŸŦ ', + '😀👋🏞ðŸ§ĐðŸ‘ĐðŸŧ‍ðŸŦðŸŦĨðŸŦ ðŸ§‘‍ðŸ’ŧðŸ‘ŧðŸĨŽ', + ]); + }); + + test('for valid non-latin', () => { + expectNoActionIssue(action, [ + 'ð Ū·é‡ŽåŪķでðĐļ―', + 'åĨˆč‰Ŋč‘›ó „€åŸŽ', // valid thanks to U+E0100 + 'åĨˆč‰Ŋč‘›åŸŽåļ‚', + 'åĨˆč‰Ŋč‘›ó „€åŸŽåļ‚', + 'åĨˆč‰ŊįœŒč‘›åŸŽåļ‚', + 'åĨˆč‰ŊįœŒč‘›ó „€åŸŽåļ‚', + 'įŦˆé–€įĶ°čą†å­', + 'įŦˆé–€įĶ°ó „€čą†å­', + 'あ𛀙よろし', + '𛁟゙んð›€ļ゙', // だんご + '𛁟゙んð›€ļã‚™ðŸĄ', + ]); + }); + }); + + describe('should return dataset with issues', () => { + const action = minCodePoints(5, 'message'); + const baseIssue: Omit< + MinCodePointsIssue, + 'input' | 'received' + > = { + kind: 'validation', + type: 'min_code_points', + expected: '>=5', + message: 'message', + requirement: 5, + }; + + test('for invalid strings', () => { + expectActionIssue( + action, + baseIssue, + ['', ' ', '1', 'foo', '1234', '12 4'], + (value) => `${_getCodePointCount(value)}` + ); + }); + + test('for invalid emoji', () => { + expectActionIssue( + action, + baseIssue, + ['1ïļâƒĢ', '😀', '😀👋🏞ðŸ§Đ'], + (value) => `${_getCodePointCount(value)}` + ); + }); + + test('for invalid non-latin', () => { + expectActionIssue( + action, + baseIssue, + [ + 'ð Ū·é‡ŽåŪķ', // billboard notation + '葛éĢū匚', + 'ð Ū·į”°åĪŠéƒŽ', + 'č‘›ó „€åŸŽåļ‚', + 'åĪĐð›‚ąã‚šð›ƒ­', // åĪĐぷら (tempura) + ], + (value) => `${_getCodePointCount(value)}` + ); + }); + }); +}); diff --git a/library/src/actions/minCodePoints/minCodePoints.ts b/library/src/actions/minCodePoints/minCodePoints.ts new file mode 100644 index 000000000..474ed0337 --- /dev/null +++ b/library/src/actions/minCodePoints/minCodePoints.ts @@ -0,0 +1,134 @@ +import type { + BaseIssue, + BaseValidation, + ErrorMessage, +} from '../../types/index.ts'; +import { _addIssue, _getCodePointCount } from '../../utils/index.ts'; + +/** + * Min code points issue type. + */ +export interface MinCodePointsIssue< + TInput extends string, + TRequirement extends number, +> extends BaseIssue { + /** + * The issue kind. + */ + readonly kind: 'validation'; + /** + * The issue type. + */ + readonly type: 'min_code_points'; + /** + * The expected property. + */ + readonly expected: `>=${TRequirement}`; + /** + * The received property. + */ + readonly received: `${number}`; + /** + * The minimum code points. + */ + readonly requirement: TRequirement; +} + +/** + * Min code points action type. + */ +export interface MinCodePointsAction< + TInput extends string, + TRequirement extends number, + TMessage extends + | ErrorMessage> + | undefined, +> extends BaseValidation< + TInput, + TInput, + MinCodePointsIssue + > { + /** + * The action type. + */ + readonly type: 'min_code_points'; + /** + * The action reference. + */ + readonly reference: typeof minCodePoints; + /** + * The expected property. + */ + readonly expects: `>=${TRequirement}`; + /** + * The minimum code points. + */ + readonly requirement: TRequirement; + /** + * The error message. + */ + readonly message: TMessage; +} + +/** + * Creates a min code points validation action. + * + * @param requirement The minimum code points. + * + * @returns A min code points action. + */ +export function minCodePoints< + TInput extends string, + const TRequirement extends number, +>( + requirement: TRequirement +): MinCodePointsAction; + +/** + * Creates a min code points validation action. + * + * @param requirement The minimum code points. + * @param message The error message. + * + * @returns A min code points action. + */ +export function minCodePoints< + TInput extends string, + const TRequirement extends number, + const TMessage extends + | ErrorMessage> + | undefined, +>( + requirement: TRequirement, + message: TMessage +): MinCodePointsAction; + +export function minCodePoints( + requirement: number, + message?: ErrorMessage> +): MinCodePointsAction< + string, + number, + ErrorMessage> | undefined +> { + return { + kind: 'validation', + type: 'min_code_points', + reference: minCodePoints, + async: false, + expects: `>=${requirement}`, + requirement, + message, + '~validate'(dataset, config) { + if (dataset.typed) { + const count = _getCodePointCount(dataset.value); + if (count < this.requirement) { + _addIssue(this, 'code_points', dataset, config, { + received: `${count}`, + }); + } + } + return dataset; + }, + }; +} diff --git a/library/src/utils/_getCodePointCount/_getCodePointCount.test.ts b/library/src/utils/_getCodePointCount/_getCodePointCount.test.ts new file mode 100644 index 000000000..937f2ab14 --- /dev/null +++ b/library/src/utils/_getCodePointCount/_getCodePointCount.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'vitest'; +import { _getCodePointCount } from './_getCodePointCountCount.ts'; + +describe('_getCodePointCount', () => { + test('should return code points count', () => { + expect(_getCodePointCount('hello world')).toBe(11); + expect(_getCodePointCount('😀')).toBe(1); + expect(_getCodePointCount('ðŸ‘ĻðŸ―â€ðŸ‘ĐðŸ―â€ðŸ‘§ðŸ―â€ðŸ‘ĶðŸ―')).toBe(11); + expect(_getCodePointCount('𝄞')).toBe(1); + expect(_getCodePointCount('ðŸ˜ķ‍ðŸŒŦïļ')).toBe(4); + expect(_getCodePointCount('įŦˆé–€įĶ°ó „€čą†å­')).toBe(6); // įĶ°ó „€ = U+79B0 (įĶ°) + U+E0100 + expect(_getCodePointCount('𛁟゙んð›€ļ゙')).toBe(5); + expect(_getCodePointCount('åĨˆč‰ŊįœŒč‘›ó „€åŸŽåļ‚')).toBe(7); + expect(_getCodePointCount('ð Ū·é‡ŽåŪķでðĐļ―')).toBe(5); + }); +}); diff --git a/library/src/utils/_getCodePointCount/_getCodePointCountCount.ts b/library/src/utils/_getCodePointCount/_getCodePointCountCount.ts new file mode 100644 index 000000000..168010686 --- /dev/null +++ b/library/src/utils/_getCodePointCount/_getCodePointCountCount.ts @@ -0,0 +1,23 @@ +/** + * Returns the code point count of the input. + * + * @param input The input to be measured. + * + * @returns The code point count. + * + * @internal + */ +export function _getCodePointCount(input: string): number { + let count: number = 0; + for (let i = 0; i < input.length; ) { + // If codePoint were undefined here, we would have already got out of loop + const codePoint = input.codePointAt(i)!; + if (codePoint <= 65535) { + i++; + } else { + i += 2; // 2 characters (surrogate pair) in JS (UTF-16) + } + count++; + } + return count; +} diff --git a/library/src/utils/_getCodePointCount/index.ts b/library/src/utils/_getCodePointCount/index.ts new file mode 100644 index 000000000..ed44da436 --- /dev/null +++ b/library/src/utils/_getCodePointCount/index.ts @@ -0,0 +1 @@ +export * from './_getCodePointCountCount.ts'; diff --git a/library/src/utils/index.ts b/library/src/utils/index.ts index 8237d3ddf..95c69d5c4 100644 --- a/library/src/utils/index.ts +++ b/library/src/utils/index.ts @@ -1,5 +1,6 @@ export * from './_addIssue/index.ts'; export * from './_getByteCount/index.ts'; +export * from './_getCodePointCount/index.ts'; export * from './_getGraphemeCount/index.ts'; export * from './_getWordCount/index.ts'; export * from './_isLuhnAlgo/index.ts';