feat(schema-form): schema-driven React form renderer package
@deepseek-ai/dsh-client-schema-form rehydrates the wire's serialized
schemastery envelope (new Schema(json)) and edits a draft user section
against it: presence-in-draft marks a field overridden with a per-field
reset, inherited values render as placeholders, role('secret') slots are
write-only with configured-state placeholders from the wire's secrets
list, dict adds take a union-typed sKey as their vocabulary, and any
node the renderer cannot faithfully edit falls back to a read-only view
instead of silently disappearing. renderField(context) is the role hook
the Models page will use for the credential-ref control; validateDraft
runs the same rehydrated validator the host uses, so the browser and
host judge one schema.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import {
|
||||
deletePath, getPath, hasPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
} from '../src/model.ts'
|
||||
|
||||
const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
|
||||
|
||||
describe('rehydration and validation', () => {
|
||||
it('rehydrates a serialized envelope into a working validator', () => {
|
||||
const root = rehydrateSchema(Wire(Schema.object({ name: Schema.string().required() })))
|
||||
expect(validateDraft(root, { name: 'ok' })).toBeUndefined()
|
||||
expect(validateDraft(root, { name: 42 })).toContain('name')
|
||||
})
|
||||
|
||||
it('stringifies non-Error validation throws', () => {
|
||||
const hostile = (() => {
|
||||
throw 'plain-string failure'
|
||||
}) as unknown as Parameters<typeof validateDraft>[0]
|
||||
expect(validateDraft(hostile, {})).toBe('plain-string failure')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeKind', () => {
|
||||
it.each([
|
||||
[Schema.object({}), 'object'],
|
||||
[Schema.dict(Schema.string()), 'dict'],
|
||||
[Schema.array(Schema.string()), 'array'],
|
||||
[Schema.string(), 'string'],
|
||||
[Schema.number(), 'number'],
|
||||
[Schema.natural(), 'number'],
|
||||
[Schema.boolean(), 'boolean'],
|
||||
[Schema.union(['a', 'b']), 'union-const'],
|
||||
[Schema.union([Schema.string(), Schema.number()]), 'unsupported'],
|
||||
[Schema.transform(Schema.string(), value => value), 'unsupported'],
|
||||
])('classifies %#', (schema, expected) => {
|
||||
expect(nodeKind(rehydrateSchema(Wire(schema as Schema)))).toBe(expected)
|
||||
})
|
||||
|
||||
it('lists union choices in declaration order', () => {
|
||||
const node = rehydrateSchema(Wire(Schema.union(['off', 'high', 'max'])))
|
||||
expect(unionChoices(node)).toEqual(['off', 'high', 'max'])
|
||||
})
|
||||
|
||||
it('tolerates structural union nodes missing their branch list', () => {
|
||||
expect(nodeKind({ type: 'union', meta: {} } as never)).toBe('union-const')
|
||||
expect(unionChoices({ type: 'union', meta: {} } as never)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('path helpers', () => {
|
||||
const root = { providers: { openai: { baseURL: 'https://x' } }, models: [{ id: 'a' }] }
|
||||
|
||||
it('reads nested object and array paths', () => {
|
||||
expect(getPath(root, [])).toBe(root)
|
||||
expect(getPath(root, ['providers', 'openai', 'baseURL'])).toBe('https://x')
|
||||
expect(getPath(root, ['models', '0', 'id'])).toBe('a')
|
||||
expect(getPath(root, ['providers', 'missing', 'x'])).toBeUndefined()
|
||||
expect(getPath(root, ['providers', 'openai', 'baseURL', 'deep'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports draft presence by key existence, not value truthiness', () => {
|
||||
expect(hasPath({ flag: false }, ['flag'])).toBe(true)
|
||||
expect(hasPath({ nested: { key: undefined } }, ['nested', 'key'])).toBe(true)
|
||||
expect(hasPath({}, ['missing'])).toBe(false)
|
||||
expect(hasPath({ leaf: 'x' }, ['leaf', 'deeper'])).toBe(false)
|
||||
expect(hasPath({ models: ['a'] }, ['models', '0'])).toBe(true)
|
||||
expect(hasPath({ models: ['a'] }, ['models', '1'])).toBe(false)
|
||||
expect(hasPath({ root: true }, [])).toBe(true)
|
||||
expect(hasPath(undefined, [])).toBe(false)
|
||||
})
|
||||
|
||||
it('sets nested paths immutably, materializing containers by key shape', () => {
|
||||
const draft = {}
|
||||
const next = setPath(draft, ['providers', 'openai', 'baseURL'], 'https://y')
|
||||
expect(draft).toEqual({})
|
||||
expect(next).toEqual({ providers: { openai: { baseURL: 'https://y' } } })
|
||||
const withArray = setPath(next, ['models', '0'], { id: 'a' })
|
||||
expect(withArray).toEqual({ providers: { openai: { baseURL: 'https://y' } }, models: [{ id: 'a' }] })
|
||||
const replaced = setPath(withArray, ['models', '0', 'id'], 'b')
|
||||
expect(replaced.models).toEqual([{ id: 'b' }])
|
||||
expect((withArray as { models: unknown[] }).models).toEqual([{ id: 'a' }])
|
||||
expect(() => setPath({}, [], 'x')).toThrow(/non-empty path/)
|
||||
})
|
||||
|
||||
it('deletes nested paths immutably and splices array indexes', () => {
|
||||
const draft = { providers: { openai: { baseURL: 'https://x', apiKey: 'k' } }, models: ['a', 'b'] }
|
||||
const withoutKey = deletePath(draft, ['providers', 'openai', 'apiKey'])
|
||||
expect(withoutKey).toEqual({ providers: { openai: { baseURL: 'https://x' } }, models: ['a', 'b'] })
|
||||
expect(draft.providers.openai.apiKey).toBe('k')
|
||||
const withoutModel = deletePath(withoutKey, ['models', '0'])
|
||||
expect(withoutModel.models).toEqual(['b'])
|
||||
expect(deletePath(draft, ['providers', 'missing', 'x'])).toBe(draft)
|
||||
expect(() => deletePath({}, [])).toThrow(/non-empty path/)
|
||||
})
|
||||
|
||||
it('deletes keys through array intermediates immutably', () => {
|
||||
const draft = { models: [{ id: 'a', contextWindow: 1 }] }
|
||||
const next = deletePath(draft, ['models', '0', 'contextWindow'])
|
||||
expect(next).toEqual({ models: [{ id: 'a' }] })
|
||||
expect(draft.models[0]).toEqual({ id: 'a', contextWindow: 1 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user