Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions library/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export * from './ipv4/index.ts';
export * from './ipv6/index.ts';
export * from './isoDate/index.ts';
export * from './isoDateTime/index.ts';
export * from './isoDuration/index.ts';
export * from './isoTime/index.ts';
export * from './isoTimeSecond/index.ts';
export * from './isoTimestamp/index.ts';
Expand Down
1 change: 1 addition & 0 deletions library/src/actions/isoDuration/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './isoDuration.ts';
36 changes: 36 additions & 0 deletions library/src/actions/isoDuration/isISO8601Duration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const ALTERNATE_FORMAT: RegExp = /^[-+]?P\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;

Check failure on line 1 in library/src/actions/isoDuration/isISO8601Duration.ts

View workflow job for this annotation

GitHub Actions / Run ESLint in library

Use the 'u' flag
const PERIOD_REGEX: RegExp = /\./g;

Check failure on line 2 in library/src/actions/isoDuration/isISO8601Duration.ts

View workflow job for this annotation

GitHub Actions / Run ESLint in library

Use the 'u' flag
const COMMA_REGEX: RegExp = /,/g;

Check failure on line 3 in library/src/actions/isoDuration/isISO8601Duration.ts

View workflow job for this annotation

GitHub Actions / Run ESLint in library

Use the 'u' flag
const WEEK_PART: RegExp = /\d+W/;
const DATE_PARTS: RegExp = /\d+[YMD]/;
const DATE_PARTS_ORDER: RegExp = /^(\d+([\.,]\d+)?Y)?(\d+([\.,]\d+)?M)?(\d+([\.,]\d+)?D)?(\d+([\.,]\d+)?W)?$/;
const TIME_PARTS_ORDER: RegExp = /^(\d+([\.,]\d+)?H)?(\d+([\.,]\d+)?M)?(\d+([\.,]\d+)?S)?$/;

export const isISO8601Duration = (input: unknown): boolean => {
if (!input || typeof input !== 'string') return false;
if (!input.startsWith('P') || input === 'P' || input === 'PT') return false;

if (ALTERNATE_FORMAT.test(input)) return true;

const totalDecimalPeriods = (input.match(PERIOD_REGEX) || []).length;
const totalDecimalCommas = (input.match(COMMA_REGEX) || []).length;
if (totalDecimalPeriods > 0 && totalDecimalCommas > 0) return false;

const parts = input.substring(1).split('T');

if (parts.length > 2) return false;

const datePart = parts[0];
const timePart = parts[1];

if (timePart === '') return false;

const hasWeeks = WEEK_PART.test(datePart);
const hasOtherDateParts = DATE_PARTS.test(datePart);
if (hasWeeks && hasOtherDateParts) return false;

if (datePart !== '' && !DATE_PARTS_ORDER.test(datePart)) return false;
if (typeof timePart === 'string' && timePart !== '' && !TIME_PARTS_ORDER.test(timePart)) return false;

return true;
}
43 changes: 43 additions & 0 deletions library/src/actions/isoDuration/isoDuration.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expectTypeOf, test } from 'vitest';
import type { InferInput, InferIssue, InferOutput } from '../../types/index.ts';
import { isoDuration, type IsoDurationAction, type IsoDurationIssue } from './isoDuration.ts';

describe('isoDuration', () => {
describe('should return action object', () => {
test('with undefined message', () => {
type Action = IsoDurationAction<string, undefined>;
expectTypeOf(isoDuration<string>()).toEqualTypeOf<Action>();
expectTypeOf(
isoDuration<string, undefined>(undefined)
).toEqualTypeOf<Action>();
});

test('with string message', () => {
expectTypeOf(isoDuration<string, 'message'>('message')).toEqualTypeOf<
IsoDurationAction<string, 'message'>
>();
});

test('with function message', () => {
expectTypeOf(
isoDuration<string, () => string>(() => 'message')
).toEqualTypeOf<IsoDurationAction<string, () => string>>();
});
});

describe('should infer correct types', () => {
type Action = IsoDurationAction<string, undefined>;

test('of input', () => {
expectTypeOf<InferInput<Action>>().toEqualTypeOf<string>();
});

test('of output', () => {
expectTypeOf<InferOutput<Action>>().toEqualTypeOf<string>();
});

test('of issue', () => {
expectTypeOf<InferIssue<Action>>().toEqualTypeOf<IsoDurationIssue<string>>();
});
});
});
157 changes: 157 additions & 0 deletions library/src/actions/isoDuration/isoDuration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, expect, test } from 'vitest';
import type { StringIssue } from '../../schemas/index.ts';
import { expectActionIssue } from '../../vitest/expectActionIssue.ts';
import { expectNoActionIssue } from '../../vitest/expectNoActionIssue.ts';
import { isoDuration, type IsoDurationAction, type IsoDurationIssue } from './isoDuration.ts';
import { isISO8601Duration } from './isISO8601Duration.ts';

describe('isoDuration', () => {
describe('should return action object', () => {
const baseAction: Omit<IsoDurationAction<string, never>, 'message'> = {
kind: 'validation',
type: 'iso_duration',
reference: isoDuration,
expects: null,
requirement: isISO8601Duration,
async: false,
'~run': expect.any(Function),
};

test('with undefined message', () => {
const action: IsoDurationAction<string, undefined> = {
...baseAction,
message: undefined,
};
expect(isoDuration()).toStrictEqual(action);
expect(isoDuration(undefined)).toStrictEqual(action);
});

test('with string message', () => {
expect(isoDuration('message')).toStrictEqual({
...baseAction,
message: 'message',
} satisfies IsoDurationAction<string, string>);
});

test('with function message', () => {
const message = () => 'message';
expect(isoDuration(message)).toStrictEqual({
...baseAction,
message,
} satisfies IsoDurationAction<string, typeof message>);
});
});

describe('should return dataset without issues', () => {
const action = isoDuration();

test('for untyped inputs', () => {
const issues: [StringIssue] = [
{
kind: 'schema',
type: 'string',
input: null,
expected: 'string',
received: 'null',
message: 'message',
},
];
expect(
action['~run']({ typed: false, value: null, issues }, {})
).toStrictEqual({
typed: false,
value: null,
issues,
});
});

test('for valid ISO Durations', () => {
expectNoActionIssue(action, [
'P1Y',
'P3M',
'P2W',
'P5D',
'PT8H',
'PT30M',
'PT10S',
'P1Y6M',
'P3Y4M2D',
'P2DT12H',
'PT1H30M',
'PT1H30M45S',
'P1Y2M3DT4H5M6S',
'P0Y0M0DT0H0M0S',
'PT0S',
'P1Y0M',
'P0Y3M',
'P10Y',
'PT36H',
'PT0.5S',
'PT1,5H',
]);
});
});

describe('should return dataset with issues', () => {
const action = isoDuration('message');
const baseIssue: Omit<IsoDurationIssue<string>, 'input' | 'received'> = {
kind: 'validation',
type: 'iso_duration',
expected: null,
message: 'message',
requirement: isISO8601Duration,
};

test('for empty strings', () => {
expectActionIssue(action, baseIssue, ['', ' ', '\n']);
});

test('for missing prefix', () => {
expectActionIssue(action, baseIssue, [
'1Y2M',
'T30M',
'5D3H',
'3Y6M2DT12H30M'
]);
});

test('for invalid order', () => {
expectActionIssue(action, baseIssue, [
'P1M2Y',
'PT30S15M',
'P3D2M1Y'
]);
});

test('for missing T separator', () => {
expectActionIssue(action, baseIssue, [
'P1Y2M3D4H5M6S',
'P5D10H',
'P10Y3M5D1H'
]);
});

test('for duplicate designators', () => {
expectActionIssue(action, baseIssue, [
'P1Y2Y3M',
'PT5H6H',
'P1Y2M3D4Q',
'P1Y2MT3HZ',
]);
});

test('for mix-matching with W designator', () => {
expectActionIssue(action, baseIssue, [
'P1Y2Y3M1W',
'P3WT5H6H'
]);
});

test('for mix-matching , and .', () => {
expectActionIssue(action, baseIssue, [
'P1.1Y2Y3MT2.5D',
'P1,5YT5.1S'
]);
});
});
});
109 changes: 109 additions & 0 deletions library/src/actions/isoDuration/isoDuration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type {
BaseIssue,
BaseValidation,
ErrorMessage,
} from '../../types/index.ts';
import { _addIssue } from '../../utils/index.ts';
import { isISO8601Duration } from "./isISO8601Duration.ts";

/**
* ISO duration issue interface.
*/
export interface IsoDurationIssue<TInput extends string> extends BaseIssue<TInput> {
/**
* The issue kind.
*/
readonly kind: 'validation';
/**
* The issue type.
*/
readonly type: 'iso_duration';
/**
* The expected property.
*/
readonly expected: null;
/**
* The received property.
*/
readonly received: `"${string}"`;
/**
* The validation function.

Check warning on line 30 in library/src/actions/isoDuration/isoDuration.ts

View workflow job for this annotation

GitHub Actions / Run ESLint in library

Expected JSDoc block to be aligned
*/
readonly requirement: (input: TInput) => boolean;
}

/**
* ISO duration action interface.
*/
export interface IsoDurationAction<
TInput extends string,
TMessage extends ErrorMessage<IsoDurationIssue<TInput>> | undefined,
> extends BaseValidation<TInput, TInput, IsoDurationIssue<TInput>> {
/**
* The action type.
*/
readonly type: 'iso_duration';
/**
* The action reference.
*/
readonly reference: typeof isoDuration;
/**
* The expected property.
*/
readonly expects: null;
/**
* The validation function.
*/
readonly requirement: (input: TInput) => boolean;
/**
* The error message.
*/
readonly message: TMessage;
}

/**
* Creates an [ISO duration](https://en.wikipedia.org/wiki/ISO_8601) validation action.
*
* Format: P[n]Y[n]M[n]DT[n]H[n]M[n]S
*
* @returns An ISO duration action.
*/
export function isoDuration<TInput extends string>(): IsoDurationAction<
TInput,
undefined
>;

/**
* Creates an [ISO duration](https://en.wikipedia.org/wiki/ISO_8601) validation action.
*
* Format: P[n]Y[n]M[n]DT[n]H[n]M[n]S
*
* @param message The error message.
*
* @returns An ISO duration action.
*/
export function isoDuration<
TInput extends string,
const TMessage extends ErrorMessage<IsoDurationIssue<TInput>> | undefined,
>(message: TMessage): IsoDurationAction<TInput, TMessage>;

// @__NO_SIDE_EFFECTS__
export function isoDuration(
message?: ErrorMessage<IsoDurationIssue<string>>
): IsoDurationAction<string, ErrorMessage<IsoDurationIssue<string>> | undefined> {
return {
kind: 'validation',
type: 'iso_duration',
reference: isoDuration,
async: false,
expects: null,
requirement: isISO8601Duration,
message,
'~run'(dataset, config) {
if (dataset.typed && !this.requirement(dataset.value)) {
_addIssue(this, 'duration', dataset, config);
}
return dataset;
},
};
}
Loading
Loading