You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/arts/sium/test/combinators.test.ts

703 lines
17 KiB

import { describe, expect, expectTypeOf, it, vi } from 'vitest';
import { array, discriminated, object, union } from '../core/combinators';
import { optional } from '../core/modifiers';
import { pipe, refine } from '../core/pipe';
import { literal, number, string } from '../core/primitives';
import { createSchema } from '../core/schema';
import {
SiumAsyncSchemaError,
SiumEncodeExpectsArrayError,
SiumEncodeExpectsObjectError,
SiumEncodeNoMatchError,
SiumValidationError
} from '../errors';
function getIssues(error: unknown) {
if (!(error instanceof SiumValidationError)) {
throw error;
}
return error.issues;
}
describe('object()', () => {
it('decodes valid objects', () => {
const schema = object({ a: string(), b: number() });
expect(schema.decodeSync({ a: 'x', b: 1 })).toEqual({ a: 'x', b: 1 });
});
it.each([
['array', ['x']],
['null', null],
['primitive', 42]
])('rejects %s inputs with an object type issue', (_, input) => {
const schema = object({ a: string(), b: number() });
try {
schema.decodeSync(input as never);
} catch (error) {
expect(getIssues(error)).toEqual([
{
path: [],
code: 'type',
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
params: {
expected: 'object',
actual: Array.isArray(input) ? 'array' : input === null ? 'null' : 'number'
}
}
]);
return;
}
throw new Error('Expected object() to reject non-object inputs');
});
it.each([
['Date', new Date('2026-01-01T00:00:00.000Z')],
['RegExp', /x/],
[
'class instance',
new (class Box {
a = 'x';
b = 1;
})()
]
])('rejects %s inputs as non-plain objects', (_, input) => {
const schema = object({ a: string(), b: number() });
try {
schema.decodeSync(input as never);
} catch (error) {
expect(getIssues(error)).toEqual([
{
path: [],
code: 'type',
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
params: {
expected: 'object',
actual: 'object'
}
}
]);
return;
}
throw new Error('Expected object() to reject non-plain objects');
});
it('accepts null-prototype objects as plain records', () => {
const schema = object({ a: string(), b: number() });
const input = Object.assign(Object.create(null) as { a: string; b: number }, {
a: 'x',
b: 1
});
expect(schema.decodeSync(input)).toEqual({ a: 'x', b: 1 });
});
it('prefixes field issues with the field key', () => {
const schema = object({ a: string(), b: number() });
try {
schema.decodeSync({ a: 42 as never, b: 1 });
} catch (error) {
expect(getIssues(error)).toEqual([
{
path: ['a'],
code: 'type',
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
params: {
expected: 'string',
actual: 'number'
}
}
]);
return;
}
throw new Error('Expected object() to prefix child field issues');
});
it('passes the absolute field path to nested refinements', () => {
const seenPaths: ReadonlyArray<string | number>[] = [];
const schema = object({
user: object({
name: pipe(
string(),
refine(
(_value, ctx) => {
seenPaths.push(ctx.path);
return false;
},
{
code: 'custom',
message: '#?sium.errors.custom|Invalid value'
}
)
)
})
});
try {
schema.decodeSync({ user: { name: 'Ada' } });
} catch (error) {
expect(seenPaths).toEqual([['user', 'name']]);
expect(getIssues(error)).toEqual([
{
path: ['user', 'name'],
code: 'custom',
message: '#?sium.errors.custom|Invalid value'
}
]);
return;
}
throw new Error('Expected nested field refine to fail');
});
it('accumulates issues from multiple invalid fields', () => {
const schema = object({ a: string(), b: number() });
try {
schema.decodeSync({ a: 42 as never, b: 'x' as never });
} catch (error) {
expect(getIssues(error)).toEqual([
{
path: ['a'],
code: 'type',
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
params: {
expected: 'string',
actual: 'number'
}
},
{
path: ['b'],
code: 'type',
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
params: {
expected: 'number',
actual: 'string'
}
}
]);
return;
}
throw new Error('Expected object() to accumulate all field issues');
});
it('strips unknown keys by default on decode', () => {
const schema = object({ a: string(), b: number() });
expect(schema.decodeSync({ a: 'x', b: 1, extra: 'e' } as never)).toEqual({
a: 'x',
b: 1
});
});
it('rejects unknown keys in strict mode', () => {
const schema = object({ a: string(), b: number() }, { unknownKeys: 'strict' });
try {
schema.decodeSync({ a: 'x', b: 1, extra: 'e' } as never);
} catch (error) {
expect(getIssues(error)).toEqual([
{
path: [],
code: 'unknown_keys',
message: '#?sium.errors.unknown_keys|Unknown keys: {{keys}}',
params: {
keys: ['extra']
}
}
]);
return;
}
throw new Error('Expected strict object() to reject unknown keys');
});
it('preserves unknown keys in passthrough mode', () => {
const schema = object({ a: string(), b: number() }, { unknownKeys: 'passthrough' });
expect(schema.decodeSync({ a: 'x', b: 1, extra: 'e' } as never)).toEqual({
a: 'x',
b: 1,
extra: 'e'
});
});
it('strips extras on encode when unknownKeys is strip', () => {
const schema = object({ a: string(), b: number() });
expect(schema.encode({ a: 'x', b: 1, extra: 'e' } as never)).toEqual({
a: 'x',
b: 1
});
});
it('throws SiumEncodeExpectsObjectError on non-plain encode input', () => {
const schema = object({ a: string(), b: number() });
expect(() => schema.encode(new Date('2026-01-01T00:00:00.000Z') as never)).toThrow(
SiumEncodeExpectsObjectError
);
});
it('rejects extras on encode when unknownKeys is strict', () => {
const schema = object({ a: string(), b: number() }, { unknownKeys: 'strict' });
try {
schema.encode({ a: 'x', b: 1, extra: 'e' } as never);
} catch (error) {
expect(getIssues(error)).toEqual([
{
path: [],
code: 'unknown_keys',
message: '#?sium.errors.unknown_keys|Unknown keys: {{keys}}',
params: {
keys: ['extra']
}
}
]);
return;
}
throw new Error('Expected strict object().encode to reject unknown keys');
});
it('exposes object introspection with fields and policy', () => {
const shape = { a: string(), b: number() };
const schema = object(shape, { unknownKeys: 'passthrough' });
expect(schema['~sium']).toEqual({
kind: 'object',
wrappers: [],
effects: [],
async: false,
meta: {},
shape: {
kind: 'object',
fields: shape,
unknownKeys: 'passthrough'
}
});
});
it('inherits async when any field schema is async', () => {
const asyncField = createSchema({
kind: 'string',
async: true,
decodeImpl: async (input: string) => input,
encode: (value: string) => value
});
const schema = object({ a: asyncField, b: number() });
expect(() => schema.decodeSync({ a: 'x', b: 1 })).toThrow(SiumAsyncSchemaError);
});
it('short-circuits async optional fields in sync object paths', () => {
const asyncField = createSchema({
kind: 'string',
async: true,
decodeImpl: async (input: string) => input,
encode: (value: string) => value
});
const schema = object({ a: optional(asyncField), b: number() });
expect(schema.decodeSync({ a: undefined, b: 1 })).toEqual({ a: undefined, b: 1 });
expect(() => schema.decodeSync({ a: 'x', b: 1 })).toThrow(SiumAsyncSchemaError);
});
});
describe('array()', () => {
it('decodes valid arrays', () => {
const schema = array(string());
expect(schema.decodeSync(['a', 'b'])).toEqual(['a', 'b']);
});
it('rejects non-array input with a type issue', () => {
const schema = array(string());
try {
schema.decodeSync('ab' as never);
} catch (error) {
expect(getIssues(error)).toEqual([
{
path: [],
code: 'type',
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
params: {
expected: 'array',
actual: 'string'
}
}
]);
return;
}
throw new Error('Expected array() to reject non-array input');
});
it('prefixes item issues with the failing index', () => {
const schema = array(string());
try {
schema.decodeSync(['a', 2 as never, 'c']);
} catch (error) {
expect(getIssues(error)).toEqual([
{
path: [1],
code: 'type',
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
params: {
expected: 'string',
actual: 'number'
}
}
]);
return;
}
throw new Error('Expected array() to prefix item issues');
});
it('passes the absolute item path to item refinements', () => {
const seenPaths: ReadonlyArray<string | number>[] = [];
const schema = object({
tags: array(
pipe(
string(),
refine(
(_value, ctx) => {
seenPaths.push(ctx.path);
return false;
},
{
code: 'custom',
message: '#?sium.errors.custom|Invalid value'
}
)
)
)
});
try {
schema.decodeSync({ tags: ['a'] });
} catch (error) {
expect(seenPaths).toEqual([['tags', 0]]);
expect(getIssues(error)).toEqual([
{
path: ['tags', 0],
code: 'custom',
message: '#?sium.errors.custom|Invalid value'
}
]);
return;
}
throw new Error('Expected array item refine to fail');
});
it('encodes item-by-item', () => {
const schema = array(string());
expect(schema.encode(['a', 'b'])).toEqual(['a', 'b']);
});
it('throws SiumEncodeExpectsArrayError on non-array encode input', () => {
const schema = array(string());
expect(() => schema.encode('ab' as never)).toThrow(SiumEncodeExpectsArrayError);
});
it('exposes array introspection', () => {
const item = string();
const schema = array(item);
expect(schema['~sium']).toEqual({
kind: 'array',
wrappers: [],
effects: [],
async: false,
meta: {},
shape: {
kind: 'array',
item
}
});
});
it('inherits async from the item schema', () => {
const asyncItem = createSchema({
kind: 'string',
async: true,
decodeImpl: async (input: string) => input,
encode: (value: string) => value
});
const schema = array(asyncItem);
expect(() => schema.decodeSync(['a'])).toThrow(SiumAsyncSchemaError);
});
it('short-circuits sync array paths that do not need async item work', () => {
const asyncItem = createSchema({
kind: 'string',
async: true,
decodeImpl: async (input: string) => input,
encode: (value: string) => value
});
expect(array(asyncItem).decodeSync([])).toEqual([]);
expect(array(optional(asyncItem)).decodeSync([undefined])).toEqual([undefined]);
expect(() => array(asyncItem).decodeSync(['a'])).toThrow(SiumAsyncSchemaError);
});
});
describe('union()', () => {
it('returns the first matching member output', () => {
const schema = union(string(), number());
expect(schema.decodeSync('x')).toBe('x');
expect(schema.decodeSync(42)).toBe(42);
expectTypeOf(schema.decodeSync('x')).toEqualTypeOf<string | number>();
});
it('emits union_no_match when no member accepts the input', () => {
const schema = union(string(), number());
try {
schema.decodeSync(true as never);
} catch (error) {
expect(getIssues(error)).toEqual([
{
path: [],
code: 'union_no_match',
message: '#?sium.errors.union_no_match|Does not match any variant',
params: {}
}
]);
return;
}
throw new Error('Expected union() to emit union_no_match');
});
it('uses identity encode in v1', () => {
const schema = union(string(), number());
expect(schema.encode('x')).toBe('x');
expect(schema.encode(42)).toBe(42);
});
it('exposes union introspection', () => {
const left = string();
const right = number();
const schema = union(left, right);
expect(schema['~sium']).toEqual({
kind: 'union',
wrappers: [],
effects: [],
async: false,
meta: {},
shape: {
kind: 'union',
members: [left, right]
}
});
});
it('inherits async when any member is async', () => {
const asyncMember = createSchema({
kind: 'string',
async: true,
decodeImpl: async (input: string) => input,
encode: (value: string) => value
});
const schema = union(asyncMember, number());
expect(() => schema.decodeSync('x')).toThrow(SiumAsyncSchemaError);
});
it('can decode sync union members before an async member is needed', () => {
const asyncMember = createSchema({
kind: 'string',
async: true,
decodeImpl: async (input: string) => input,
encode: (value: string) => value
});
expect(union(number(), asyncMember).decodeSync(1)).toBe(1);
expect(() => union(asyncMember, number()).decodeSync(1 as never)).toThrow(SiumAsyncSchemaError);
});
});
describe('discriminated()', () => {
it('delegates to the matching member on decode', () => {
const schema = discriminated('kind', [
object({ kind: literal('a'), val: string() }),
object({ kind: literal('b'), val: number() })
]);
expect(schema.decodeSync({ kind: 'a', val: 'x' })).toEqual({ kind: 'a', val: 'x' });
expect(schema.decodeSync({ kind: 'b', val: 1 })).toEqual({ kind: 'b', val: 1 });
});
it('rejects non-object inputs with a type issue', () => {
const schema = discriminated('kind', [
object({ kind: literal('a'), val: string() }),
object({ kind: literal('b'), val: number() })
]);
try {
schema.decodeSync('x' as never);
} catch (error) {
expect(getIssues(error)).toEqual([
{
path: [],
code: 'type',
message: '#?sium.errors.type|Expected {{expected}} but received {{actual}}',
params: {
expected: 'object',
actual: 'string'
}
}
]);
return;
}
throw new Error('Expected discriminated() to reject non-object input');
});
it('emits discriminated_missing_key when the discriminator is absent', () => {
const schema = discriminated('kind', [
object({ kind: literal('a'), val: string() }),
object({ kind: literal('b'), val: number() })
]);
try {
schema.decodeSync({ val: 'x' } as never);
} catch (error) {
expect(getIssues(error)).toEqual([
{
path: [],
code: 'discriminated_missing_key',
message: '#?sium.errors.discriminated_missing_key|Missing discriminator key "{{key}}"',
params: {
key: 'kind'
}
}
]);
return;
}
throw new Error('Expected discriminated() to emit missing-key issue');
});
it('emits discriminated_unknown_value when the discriminator is not recognized', () => {
const schema = discriminated('kind', [
object({ kind: literal('a'), val: string() }),
object({ kind: literal('b'), val: number() })
]);
try {
schema.decodeSync({ kind: 'c', val: 'x' } as never);
} catch (error) {
expect(getIssues(error)).toEqual([
{
path: [],
code: 'discriminated_unknown_value',
message:
'#?sium.errors.discriminated_unknown_value|Unknown value "{{actual}}" for discriminator "{{key}}" (expected one of {{expected}})',
params: {
key: 'kind',
actual: 'c',
expected: ['a', 'b']
}
}
]);
return;
}
throw new Error('Expected discriminated() to emit unknown-value issue');
});
it('fails at construction when a member lacks a literal discriminator', () => {
expect(() =>
discriminated('kind', [
object({ kind: literal('a'), val: string() }),
object({ kind: string(), val: number() })
])
).toThrow(Error);
});
it('encodes through the matching member', () => {
const schema = discriminated('kind', [
object({ kind: literal('a'), val: string() }),
object({ kind: literal('b'), val: number() })
]);
expect(schema.encode({ kind: 'a', val: 'x' })).toEqual({ kind: 'a', val: 'x' });
expect(schema.encode({ kind: 'b', val: 1 })).toEqual({ kind: 'b', val: 1 });
});
it('exposes discriminated introspection', () => {
const first = object({ kind: literal('a'), val: string() });
const second = object({ kind: literal('b'), val: number() });
const schema = discriminated('kind', [first, second]);
expect(schema['~sium']).toEqual({
kind: 'discriminated',
wrappers: [],
effects: [],
async: false,
meta: {},
shape: {
kind: 'discriminated',
key: 'kind',
members: [first, second]
}
});
});
it('inherits async when any member is async', () => {
const asyncValue = createSchema({
kind: 'string',
async: true,
decodeImpl: async (input: string) => input,
encode: (value: string) => value
});
const schema = discriminated('kind', [
object({ kind: literal('a'), val: asyncValue }),
object({ kind: literal('b'), val: number() })
]);
expect(() => schema.decodeSync({ kind: 'a', val: 'x' })).toThrow(SiumAsyncSchemaError);
});
it('short-circuits selected discriminated member paths when async work is not needed', () => {
const asyncValue = createSchema({
kind: 'string',
async: true,
decodeImpl: async (input: string) => input,
encode: (value: string) => value
});
const schema = discriminated('kind', [
object({ kind: literal('a'), val: optional(asyncValue) }),
object({ kind: literal('b'), val: number() })
]);
expect(schema.decodeSync({ kind: 'a', val: undefined })).toEqual({
kind: 'a',
val: undefined
});
expect(schema.decodeSync({ kind: 'b', val: 1 })).toEqual({ kind: 'b', val: 1 });
expect(() => schema.decodeSync({ kind: 'a', val: 'x' })).toThrow(SiumAsyncSchemaError);
});
});

Powered by TurnKey Linux.