feat(lib): zod schema and tests
This commit is contained in:
parent
246c7275fd
commit
c92673f6f1
3 changed files with 350 additions and 0 deletions
201
src/lib/schema.test.ts
Normal file
201
src/lib/schema.test.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildCharacterSchema } from './schema';
|
||||||
|
import type { Template } from './types';
|
||||||
|
|
||||||
|
function makeTemplate(fields: any[]): Template {
|
||||||
|
return {
|
||||||
|
id: 'test',
|
||||||
|
name: 'Test',
|
||||||
|
description: '',
|
||||||
|
schemaVersion: 1,
|
||||||
|
records: [{ type: 'public', expanded: true, fields }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildCharacterSchema', () => {
|
||||||
|
it('validates text fields as optional strings', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([{ key: 'pronouns', label: 'Pronouns', type: 'text' }])
|
||||||
|
);
|
||||||
|
expect(schema.parse({})).toEqual({});
|
||||||
|
expect(schema.parse({ pronouns: 'She/her' })).toEqual({ pronouns: 'She/her' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates textarea fields as optional strings', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([
|
||||||
|
{ key: 'distinguishing-features', label: 'Distinguishing Features', type: 'textarea' }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ 'distinguishing-features': 'Scar across left eye' })).toEqual({
|
||||||
|
'distinguishing-features': 'Scar across left eye'
|
||||||
|
});
|
||||||
|
expect(schema.parse({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates list fields as optional strings', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([
|
||||||
|
{ key: 'employment-history', label: 'Employment History', type: 'list' }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ 'employment-history': 'NanoTrasen Intern\nShaft Miner' })).toEqual({
|
||||||
|
'employment-history': 'NanoTrasen Intern\nShaft Miner'
|
||||||
|
});
|
||||||
|
expect(schema.parse({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates date fields as optional strings', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([
|
||||||
|
{ key: 'date-of-birth', label: 'Date of Birth', type: 'date', placeholder: 'March 15th, 2438' }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ 'date-of-birth': 'March 15th, 2438' })).toEqual({
|
||||||
|
'date-of-birth': 'March 15th, 2438'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates select fields as optional strings', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([
|
||||||
|
{
|
||||||
|
key: 'citizenship',
|
||||||
|
label: 'Citizenship',
|
||||||
|
type: 'select',
|
||||||
|
options: [{ value: 'biesel', label: 'Republic of Biesel' }]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ citizenship: 'biesel' })).toEqual({ citizenship: 'biesel' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates number fields as optional numbers', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([{ key: 'age', label: 'Age', type: 'number', min: 0, max: 999 }])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ age: 30 })).toEqual({ age: 30 });
|
||||||
|
expect(schema.parse({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates height as optional number', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([{ key: 'height', label: 'Height', type: 'height' }])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ height: 180 })).toEqual({ height: 180 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates weight as optional number', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([{ key: 'weight', label: 'Weight', type: 'weight' }])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ weight: 75 })).toEqual({ weight: 75 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates name as optional string', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([{ key: 'name', label: 'Name', type: 'text' }])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ name: 'Ka\'Akaix\'Lak Zo\'ra' })).toEqual({
|
||||||
|
name: 'Ka\'Akaix\'Lak Zo\'ra'
|
||||||
|
});
|
||||||
|
expect(schema.parse({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates multi-select as optional string array', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([
|
||||||
|
{
|
||||||
|
key: 'spoken-languages',
|
||||||
|
label: 'Spoken Languages',
|
||||||
|
type: 'multi-select',
|
||||||
|
options: [
|
||||||
|
{ value: 'tau-ceti-basic', label: 'Tau Ceti Basic' },
|
||||||
|
{ value: 'sol-common', label: 'Sol Common' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ 'spoken-languages': ['tau-ceti-basic', 'sol-common'] })).toEqual({
|
||||||
|
'spoken-languages': ['tau-ceti-basic', 'sol-common']
|
||||||
|
});
|
||||||
|
expect(schema.parse({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates checkbox as optional string array', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([
|
||||||
|
{
|
||||||
|
key: 'opt-outs',
|
||||||
|
label: 'Opt-Outs',
|
||||||
|
type: 'checkbox',
|
||||||
|
options: [
|
||||||
|
{ value: 'no-borg', label: 'Do Not Borgify' },
|
||||||
|
{ value: 'no-revive', label: 'Do Not Revive' },
|
||||||
|
{ value: 'no-prosthetic', label: 'Do Not Prostheticize' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ 'opt-outs': ['no-borg', 'no-revive'] })).toEqual({
|
||||||
|
'opt-outs': ['no-borg', 'no-revive']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates languages as optional string array', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([
|
||||||
|
{ key: 'spoken-languages', label: 'Spoken Languages', type: 'languages' }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
schema.parse({ 'spoken-languages': ['Tau Ceti Basic', 'Siik\'maas'] })
|
||||||
|
).toEqual({
|
||||||
|
'spoken-languages': ['Tau Ceti Basic', 'Siik\'maas']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates species as optional string', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([{ key: 'species', label: 'Species', type: 'species' }])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ species: 'tajara' })).toEqual({ species: 'tajara' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates subspecies as optional string', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([{ key: 'subspecies', label: 'Subspecies', type: 'subspecies' }])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ subspecies: 'zhan-khazan' })).toEqual({
|
||||||
|
subspecies: 'zhan-khazan'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates citizenship type as optional string', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([{ key: 'citizenship', label: 'Citizenship', type: 'citizenship' }])
|
||||||
|
);
|
||||||
|
expect(schema.parse({ citizenship: 'sol-alliance' })).toEqual({
|
||||||
|
citizenship: 'sol-alliance'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows all fields to be missing', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([
|
||||||
|
{ key: 'name', label: 'Name', type: 'text' },
|
||||||
|
{ key: 'height', label: 'Height', type: 'height' },
|
||||||
|
{ key: 'spoken-languages', label: 'Spoken Languages', type: 'languages' },
|
||||||
|
{ key: 'skin-color', label: 'Skin Color', type: 'text' }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(schema.parse({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong types', () => {
|
||||||
|
const schema = buildCharacterSchema(
|
||||||
|
makeTemplate([{ key: 'height', label: 'Height', type: 'height' }])
|
||||||
|
);
|
||||||
|
expect(() => schema.parse({ height: 'tall' })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/lib/schema.ts
Normal file
36
src/lib/schema.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { FieldDef, Template } from './types';
|
||||||
|
|
||||||
|
function zodForField(field: FieldDef): z.ZodTypeAny {
|
||||||
|
switch (field.type) {
|
||||||
|
case 'text':
|
||||||
|
case 'textarea':
|
||||||
|
case 'list':
|
||||||
|
case 'date':
|
||||||
|
case 'select':
|
||||||
|
case 'species':
|
||||||
|
case 'subspecies':
|
||||||
|
case 'citizenship':
|
||||||
|
return z.string().optional();
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
case 'height':
|
||||||
|
case 'weight':
|
||||||
|
return z.number().optional();
|
||||||
|
|
||||||
|
case 'multi-select':
|
||||||
|
case 'checkbox':
|
||||||
|
case 'languages':
|
||||||
|
return z.array(z.string()).optional();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCharacterSchema(template: Template): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
||||||
|
const shape: Record<string, z.ZodTypeAny> = {};
|
||||||
|
for (const record of template.records) {
|
||||||
|
for (const field of record.fields) {
|
||||||
|
shape[field.key] = zodForField(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return z.object(shape).partial();
|
||||||
|
}
|
||||||
113
src/lib/types.ts
Normal file
113
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseFieldDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextField extends BaseFieldDef {
|
||||||
|
type: 'text';
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextareaField extends BaseFieldDef {
|
||||||
|
type: 'textarea';
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListField extends BaseFieldDef {
|
||||||
|
type: 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberField extends BaseFieldDef {
|
||||||
|
type: 'number';
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectField extends BaseFieldDef {
|
||||||
|
type: 'select';
|
||||||
|
options: SelectOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiSelectField extends BaseFieldDef {
|
||||||
|
type: 'multi-select';
|
||||||
|
options: SelectOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckboxField extends BaseFieldDef {
|
||||||
|
type: 'checkbox';
|
||||||
|
options: SelectOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateField extends BaseFieldDef {
|
||||||
|
type: 'date';
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeightField extends BaseFieldDef {
|
||||||
|
type: 'height';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeightField extends BaseFieldDef {
|
||||||
|
type: 'weight';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeciesField extends BaseFieldDef {
|
||||||
|
type: 'species';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubspeciesField extends BaseFieldDef {
|
||||||
|
type: 'subspecies';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CitizenshipField extends BaseFieldDef {
|
||||||
|
type: 'citizenship';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LanguagesField extends BaseFieldDef {
|
||||||
|
type: 'languages';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldDef =
|
||||||
|
| TextField
|
||||||
|
| TextareaField
|
||||||
|
| ListField
|
||||||
|
| NumberField
|
||||||
|
| SelectField
|
||||||
|
| MultiSelectField
|
||||||
|
| CheckboxField
|
||||||
|
| DateField
|
||||||
|
| HeightField
|
||||||
|
| WeightField
|
||||||
|
| SpeciesField
|
||||||
|
| SubspeciesField
|
||||||
|
| CitizenshipField
|
||||||
|
| LanguagesField;
|
||||||
|
|
||||||
|
export interface RecordDef {
|
||||||
|
type: string;
|
||||||
|
preamble?: string;
|
||||||
|
expanded: boolean;
|
||||||
|
fields: FieldDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Template {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
records: RecordDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Character {
|
||||||
|
id: string;
|
||||||
|
template: Template;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue