import { expectType, Result } from 'ts-data-forge'; import { type UnknownRecord } from 'ts-type-forge'; import { array } from '../array/index.mjs'; import { number } from '../primitives/index.mjs'; import { type Type, type TypeOf } from '../type.mjs'; import { validationErrorsToMessages } from '../utils/index.mjs'; import { optional } from './optional.mjs'; import { record } from './record.mjs'; describe(record, () => { const ymd = record({ year: number(1900), month: number(1), date: number(1), }); const ymdStrict = record( { year: number(1900), month: optional(number(1)), date: optional(number(1)), }, { excessProperty: 'reject' }, ); expectType< typeof ymd, Type> >('='); type Ymd = TypeOf; expectType>('='); expectType>('='); // record(shape) and record(shape, { excessProperty: 'allow' }) produce the same type const _ymdExplicitAllow = record( { year: number(1900), month: number(1), date: number(1), }, { excessProperty: 'allow' }, ); expectType('='); describe('is', () => { test('truthy case', () => { const x: UnknownRecord = { year: 2000, month: 12, date: 12, } as const; if (ymd.is(x)) { expectType('='); } else { expectType('='); } assert.isTrue(ymd.is(x)); }); test('falsy case', () => { const x: UnknownRecord = { year: 2000, month: 'ab', date: 'cd', } as const; if (ymd.is(x)) { expectType('='); } else { expectType('='); } assert.isFalse(ymd.is(x)); }); }); describe('validate', () => { test('truthy case', () => { const x: UnknownRecord = { year: 2000, month: 12, date: 25, } as const; const result = ymd.validate(x); assert.isTrue(Result.isOk(result)); const resultValue = Result.unwrapThrow(result); expectType('='); assert.deepStrictEqual(resultValue, { year: 2000, month: 12, date: 25, }); }); test('falsy case 1', () => { const x: UnknownRecord = { year: 2000, month: 'ab', date: 'cd', } as const; const result = ymd.validate(x); assert.isTrue(Result.isErr(result)); const resultError = Result.unwrapErrThrow(result); assert.deepStrictEqual(resultError, [ { path: ['month'], actualValue: 'ab', expectedType: 'number', typeName: 'number', details: undefined, }, { path: ['date'], actualValue: 'cd', expectedType: 'number', typeName: 'number', details: undefined, }, ]); assert.deepStrictEqual(validationErrorsToMessages(resultError), [ 'Error at month: expected type but type value "ab" was passed.', 'Error at date: expected type but type value "cd" was passed.', ]); }); test('falsy case 2', () => { const x: UnknownRecord = { year: 2000, } as const; const result = ymd.validate(x); assert.isTrue(Result.isErr(result)); const resultError1 = Result.unwrapErrThrow(result); assert.deepStrictEqual(resultError1, [ { path: ['month'], actualValue: { year: 2000 }, typeName: '{ year: number, month: number, date: number }', expectedType: '{ year: number, month: number, date: number }', details: { kind: 'missing-key', key: 'month', }, }, { path: ['date'], actualValue: { year: 2000 }, typeName: '{ year: number, month: number, date: number }', expectedType: '{ year: number, month: number, date: number }', details: { kind: 'missing-key', key: 'date', }, }, ]); assert.deepStrictEqual(validationErrorsToMessages(resultError1), [ 'Error at month: missing required key "month".', 'Error at date: missing required key "date".', ]); }); test('validate returns input as-is (with excess) for OK cases since default excessProperty is "allow"', () => { const input = { year: 2023, month: 6, date: 15, extra: 'should be kept', } as const; const result = ymd.validate(input); assert.isTrue(Result.isOk(result)); const resultValue1 = Result.unwrapThrow(result); // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion assert.deepStrictEqual(resultValue1 as UnknownRecord, { year: 2023, month: 6, date: 15, extra: 'should be kept', }); expect(resultValue1).toBe(input); // ✅ same reference }); test('validate rejects excess properties if excessProperty is "reject"', () => { const input = { year: 2023, month: 6, extra: 'not allowed', } as const; const result = ymdStrict.validate(input); assert.isTrue(Result.isErr(result)); }); test('validate accepts valid data without excess when excessProperty is "reject"', () => { const input = { year: 2023, month: 6, } as const; const result = ymdStrict.validate(input); assert.isTrue(Result.isOk(result)); expect(Result.unwrapThrow(result)).toBe(input); // ✅ same reference }); }); describe('fill', () => { test('from an empty record', () => { const x: UnknownRecord = {} as const; assert.deepStrictEqual(ymd.fill(x), { year: 1900, month: 1, date: 1, }); }); test('from a filled record', () => { const x: UnknownRecord = { year: 2000, month: 999, date: 999, } as const; assert.deepStrictEqual(ymd.fill(x), { year: 2000, month: 999, date: 999, }); }); test('from a partial record', () => { const x: UnknownRecord = { year: 2000, } as const; assert.deepStrictEqual(ymd.fill(x), { year: 2000, month: 1, date: 1, }); }); test('from a partial record with excess property', () => { const x: UnknownRecord = { year: 2000, aaaaa: 9999, } as const; assert.deepStrictEqual(ymd.fill(x), { year: 2000, month: 1, date: 1, }); }); }); describe('prune', () => { test('removes excess properties', () => { assert.deepStrictEqual( ymd.prune({ year: 2000, month: 12, date: 31, aaaaa: 9999 }), { year: 2000, month: 12, date: 31, }, ); }); test('keeps all represented paths as-is (never fills defaults)', () => { assert.deepStrictEqual(ymd.prune({ year: 2000, month: 999, date: 999 }), { year: 2000, month: 999, date: 999, }); }); test('rejects a value with missing keys at type level', () => { const pruned: UnknownRecord = // @ts-expect-error missing key: "date" ymd.prune({ year: 2000, month: 12 }); // runtime: prune assumes type-conforming input, so a missing required // key just yields undefined (never a default value) assert.deepStrictEqual(pruned, { year: 2000, month: 12, date: undefined, }); }); test('prunes nested records recursively', () => { const nested = record({ id: number(0), pos: record({ x: number(0), y: number(0) }), }); assert.deepStrictEqual( nested.prune({ id: 1, pos: { x: 2, y: 3, z: 4 }, excess: 'excess', }), { id: 1, pos: { x: 2, y: 3 }, }, ); }); test('prunes records inside arrays recursively', () => { const nested = record({ points: array(record({ x: number(0), y: number(0) })), }); assert.deepStrictEqual( nested.prune({ points: [ { x: 1, y: 2, z: 3 }, { x: 4, y: 5 }, ], excess: 'excess', }), { points: [ { x: 1, y: 2 }, { x: 4, y: 5 }, ], }, ); }); }); }); describe('partial record', () => { const ymd = record({ year: number(1900), month: optional(number(1)), date: optional(number(1)), }); const ymdStrict = record( { year: number(1900), month: optional(number(1)), date: optional(number(1)), }, { excessProperty: 'reject' }, ); type Ymd = TypeOf; expectType>( '=', ); expectType>('='); describe('is', () => { test('truthy case', () => { const x: UnknownRecord = { year: 2000, month: 12, } as const; if (ymd.is(x)) { expectType('='); } else { expectType('='); } assert.isTrue(ymd.is(x)); }); test('falsy case 1', () => { const x: UnknownRecord = { year: 2000, month: 'ab', date: 'cd', } as const; if (ymd.is(x)) { expectType('='); } else { expectType('='); } assert.isFalse(ymd.is(x)); }); test('falsy case 2', () => { const x: UnknownRecord = { date: 'cd', } as const; if (ymd.is(x)) { expectType('='); } else { expectType('='); } assert.isFalse(ymd.is(x)); }); }); describe('validate', () => { test('truthy case', () => { const x: UnknownRecord = { year: 2000, month: 12, } as const; const result = ymd.validate(x); assert.isTrue(Result.isOk(result)); const resultValue2 = Result.unwrapThrow(result); expectType('='); assert.deepStrictEqual(resultValue2, { year: 2000, month: 12, }); }); test('falsy case', () => { const x: UnknownRecord = { year: 2000, month: 'ab', date: 'cd', } as const; const result = ymd.validate(x); assert.isTrue(Result.isErr(result)); const resultError2 = Result.unwrapErrThrow(result); assert.deepStrictEqual(resultError2, [ { path: ['month'], actualValue: 'ab', expectedType: 'number', typeName: 'number', details: undefined, }, { path: ['date'], actualValue: 'cd', expectedType: 'number', typeName: 'number', details: undefined, }, ]); assert.deepStrictEqual(validationErrorsToMessages(resultError2), [ 'Error at month: expected type but type value "ab" was passed.', 'Error at date: expected type but type value "cd" was passed.', ]); }); test('validate returns input as-is (with excess) for OK cases since default excessProperty is "allow"', () => { const input = { year: 2024, month: 8, extra: 'should be kept', } as const; const result = ymd.validate(input); assert.isTrue(Result.isOk(result)); const resultValue3 = Result.unwrapThrow(result); // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion assert.deepStrictEqual(resultValue3 as UnknownRecord, { year: 2024, month: 8, extra: 'should be kept', }); expect(resultValue3).toBe(input); // ✅ same reference }); test('validate rejects excess properties if excessProperty is "reject"', () => { const input = { year: 2024, month: 8, extra: 'not allowed', } as const; const result = ymdStrict.validate(input); assert.isTrue(Result.isErr(result)); }); test('validate accepts valid data without excess when excessProperty is "reject"', () => { const input = { year: 2024, month: 8, } as const; const result = ymdStrict.validate(input); assert.isTrue(Result.isOk(result)); expect(Result.unwrapThrow(result)).toBe(input); // ✅ same reference }); }); describe('fill', () => { test('from an empty record', () => { const x: UnknownRecord = {} as const; assert.deepStrictEqual(ymd.fill(x), { year: 1900, month: 1, date: 1, }); }); test('from a filled record', () => { const x: UnknownRecord = { year: 2000, month: 999, date: 999, } as const; assert.deepStrictEqual(ymd.fill(x), { year: 2000, month: 999, date: 999, }); }); test('from a partial record', () => { const x: UnknownRecord = { year: 2000, } as const; assert.deepStrictEqual(ymd.fill(x), { year: 2000, month: 1, date: 1, }); }); test('from a partial record with excess property', () => { const x: UnknownRecord = { year: 2000, aaaaa: 9999, } as const; assert.deepStrictEqual(ymd.fill(x), { year: 2000, month: 1, date: 1, }); }); }); describe('prune', () => { test('removes excess properties and keeps present optional keys', () => { assert.deepStrictEqual(ymd.prune({ year: 2000, month: 6, aaaaa: 9999 }), { year: 2000, month: 6, }); }); test('does not fill in missing optional keys', () => { assert.deepStrictEqual(ymd.prune({ year: 2000 }), { year: 2000 }); }); }); });