diff --git a/AGENTS.md b/AGENTS.md index 05ff892..f692dd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,9 @@ Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleColl `validateFormanWithDomainsInternal` is the core; it builds a `roots` map per domain then calls `validateFormanValue` recursively. Handlers: `handleCollectionType`, `handleArrayType`, `handleSelectType`, `handleFilterType`, `handlePathType`, `handlePrimitiveType`, `handleNestedFields`, `handleBooleanNestedFields`. `resolveRemote` is wrapped into a closure that merges `context.tail` into the `data` argument. -`validateForman` always wraps to a single `default` domain. Result type: `{ valid, errors[], warnings[], states?, schemas? }`. `warnings` do not affect `valid`. `states` populated only when `options.states === true` AND no errors. `schemas` populated only when `options.schemas === true` AND no errors — returns resolved field definitions per domain. +`validateForman` always wraps to a single `default` domain. Result type: `{ valid, errors[], warnings[], states?, schemas?, resolvedSchemas?, normalizedValues, appliedDefaults }` (`FormanNormalizedValidationResult`; `normalizedValues`/`appliedDefaults` are always present on entry-point results — without fills they echo the input values / an empty list). `warnings` do not affect `valid`. `states` populated only when `options.states === true` AND no errors. `schemas` populated only when `options.schemas === true` AND no errors — returns resolved field definitions per domain. `resolvedSchemas` is the same data as `schemas` but present on the failure path too, so a rejection can name remote-resolved fields the caller never saw. + +**Default filling** (`options.fillDefaults: 'requiredOnly' | 'always'`, off by default; contract in the `FormanValidationOptions` JSDoc): the substitution happens just before the mandatory check in `validateFormanValue` — an `undefined` or `''` value with a non-`null`/`''` default fills (BlueprintValidator's `useDefaults` predicate and modes; explicit `null` stays a provided value). `'requiredOnly'` fills required fields only; `'always'` fills omitted optional fields too. The filled value flows through the rest of the walk and defaults under an armed nested branch fill recursively, `rpc://`-resolved specs included. Nothing fills under `suppressRequired` (inactive branches). Fills are recorded per domain root (`DomainRoot.appliedDefaults`, raw path segments); the result exposes `appliedDefaults` (dot-joined paths, matching error paths) and `normalizedValues`, built with `setValueAtPath` (`src/utils.ts`, copy-on-write along the path; inputs never mutated). Both are present on success and failure, since a filled default can arm requirements the caller still has to repair. Per-domain inputs accept `restoreExtras` (extra values injected into restore states, keyed by dot-notation path) and `allowDynamicValues` (when true, IML expressions and unresolved RPC select options produce warnings instead of errors; default false). `allowDynamicValues` can also be set globally via `FormanValidationOptions`. diff --git a/README.md b/README.md index b0d147e..e6e156a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ Conversion and validation utilities for Forman Schema. +## v2.0.0 — validated values on every result + +`validateForman` and `validateFormanWithDomains` now always return `normalizedValues` and +`appliedDefaults`. Nothing was removed or renamed and `valid`/`errors`/`warnings` are unaffected, +but a caller that deep-compares the whole result, or forwards it into a fixed-shape response, will +see two new keys — assert on the fields you care about, or drop the keys before forwarding. + +Also new: `fillDefaults: 'always'`. See [Filling defaults](#filling-defaults). + ## v1.14.0 — advanced field tracking Non-breaking minor release. New surface for working with `advanced: true` Forman fields: @@ -137,7 +146,16 @@ Validate Forman values against a Forman Schema. Two entry points are available: - `validateForman(values, schema, options?)` — validate without domains. - `validateFormanWithDomains(domains, options?)` — validate multiple domains at once. -Both return `{ valid: boolean, errors: { path: string, message: string }[] }`. +Both return `{ valid: boolean, errors: { path: string, message: string }[] }`, plus +`normalizedValues` (the input values per domain, with any filled defaults applied — see +[Filling defaults](#filling-defaults)) and `appliedDefaults` (what was filled, empty when +nothing was). The two are always present, so the consuming pattern is the same whether or +not default filling is enabled: + +```typescript +const { valid, errors, normalizedValues } = await validateForman(values, schema); +if (valid) persist(normalizedValues.default); +``` #### Basic validation @@ -219,6 +237,43 @@ const result = await validateForman(values, schema, { }); ``` +#### Filling defaults + +With `fillDefaults: 'requiredOnly'`, an omitted required field whose schema declares a usable +default (`null` and `''` cannot satisfy a required check) validates as that default instead of +failing as mandatory. With `fillDefaults: 'always'`, omitted optional fields with usable defaults +are filled too — the same modes as the platform's BlueprintValidator `useDefaults` option. The +filled value participates in the rest of the walk, so a filled boolean conditions its nested branch +exactly as a provided one would, and defaults under an armed branch fill recursively — including +fields injected by `rpc://`-resolved specs. Fills land in `normalizedValues` (the values with fills +applied; the input is never mutated, though subtrees nothing was written into are shared with it) +and are itemized in `appliedDefaults`, on the failure path too, so remaining errors can be repaired +on top of the filled values. Values you provide are never overwritten, **except `''`, which counts as +an omission and fills** — matching blueprint validation and the builder UI. Under `'always'` that +means an optional field you deliberately cleared comes back with its default; pass `'requiredOnly'` +if you need a cleared optional field left alone. An explicit `null` is a provided value: it never +fills and still fails as mandatory. Inactive nested branches are never filled. + +```typescript +const schema = [ + { + name: 'fallbackEnabled', + type: 'boolean', + required: true, + default: false, + nested: [{ name: 'fallbackConnectionId', type: 'text', required: true }], + }, +]; + +const result = await validateForman({}, schema, { fillDefaults: 'requiredOnly' }); +// { +// valid: true, +// errors: [], +// normalizedValues: { default: { fallbackEnabled: false } }, +// appliedDefaults: [{ domain: 'default', path: 'fallbackEnabled', value: false }] +// } +``` + #### Multi-domain validation Use `validateFormanWithDomains` to validate cross-domain schemas (e.g., `default` and `additional`). diff --git a/src/index.ts b/src/index.ts index 3fd911c..05b1814 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import type { JSONSchema7 } from 'json-schema'; import { toJSONSchemaInternal, createDefaultContext } from './forman'; import type { FormanSchemaField, - FormanValidationResult, + FormanNormalizedValidationResult, FormanValidationOptions, FormanJsonSchemaOptions, FormanJsonSchemaResult, @@ -24,6 +24,7 @@ export type { FormanSchemaRPCButton, FormanValidationOptions, FormanValidationResult, + FormanNormalizedValidationResult, FormanExternalValidationResult, FormanJsonSchemaOptions, FormanJsonSchemaResult, @@ -130,7 +131,7 @@ export function validateFormanWithDomains( } >, options?: FormanValidationOptions, -): Promise { +): Promise { return validateFormanWithDomainsInternal(domains, options); } @@ -149,7 +150,7 @@ export function validateForman( schema: FormanSchemaField[], options?: FormanValidationOptions, restoreExtras?: Record>, -): Promise { +): Promise { return validateFormanWithDomains( { default: { values, schema, restoreExtras, allowDynamicValues: options?.allowDynamicValues } }, options, diff --git a/src/types.ts b/src/types.ts index a8cd686..c4395ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -292,8 +292,31 @@ export type FormanValidationResult = { * which fields it rejected. */ resolvedSchemas?: Record; + /** + * The input values with filled defaults applied, per domain, so a caller can persist or repair + * the filled configuration alongside any remaining errors. The input `values` are never + * mutated, but subtrees no default was written into are shared with the input — with no fills + * the domain's entry IS the caller's own object. + */ + normalizedValues?: Record>; + /** The defaults that were filled (`options.fillDefaults`), in walk order within each domain. */ + appliedDefaults?: { + domain: string; + path: string; + /** The default that was filled in. Loosely typed on purpose: `default` is declared as + * `FormanSchemaValue`, but schemas are JSON at source and may carry object or array + * defaults at runtime, which are filled as (cloned) values too. */ + value: unknown; + }[]; }; +/** + * The type returned by `validateForman` and `validateFormanWithDomains`. The fields stay optional + * on the base type because intermediate results assembled during the walk do not carry them. + */ +export type FormanNormalizedValidationResult = FormanValidationResult & + Required>; + export type FormanSchemaFieldState = { mode?: 'chose' | 'edit'; label?: string; @@ -379,6 +402,20 @@ export type FormanValidationOptions = { schema: JSONSchema7, value: unknown, ): FormanExternalValidationResult | Promise; + /** Fill declared defaults for fields the caller omitted, mirroring BlueprintValidator's + * `useDefaults` modes. `'requiredOnly'` fills only required fields (instead of failing them + * as mandatory); `'always'` also fills omitted optional fields. A field is fillable when its + * value is `undefined` or `''` and its schema declares a default that is not `null` or `''` + * (a default that could not satisfy a required check) — the same fillable predicate as + * BlueprintValidator and the builder UI. The filled value flows through the rest of the + * walk, so a filled boolean conditions its nested branch exactly as a provided one would + * (`false` leaves the branch inactive), and defaults under an armed branch fill + * recursively, in the same single pass. Fills are reported on `normalizedValues` and + * `appliedDefaults`. Values the caller provided are never overwritten, except `''`, which + * counts as an omission and fills — so under `'always'` a deliberately cleared optional + * field comes back with its default. An explicit `null` is a provided value: it never fills + * and still fails as mandatory. Inactive branches are never filled. */ + fillDefaults?: 'requiredOnly' | 'always'; /** Maps domain names used in nested.domain to actual domain keys passed to validateFormanWithDomains */ domainAliases?: Record; /** Whether to allow dynamic values (IML expressions, unresolved RPC options). diff --git a/src/utils.ts b/src/utils.ts index 3cb5c23..f08c1d8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -192,6 +192,41 @@ export function findValueInSelectOptions( return found as FormanSchemaOption | undefined; // If there was a value, then it has to be an option, because option groups don't have values } +function setIn(container: unknown, path: Array, value: unknown): unknown { + const [head, ...rest] = path; + if (head === undefined) return value; + if (typeof head === 'number') { + const items = Array.isArray(container) ? container.slice() : []; + items[head] = setIn(items[head], rest, value); + return items; + } + const record: Record = isObject>(container) ? { ...container } : {}; + record[head] = setIn(record[head], rest, value); + return record; +} + +/** + * Returns a copy of `values` with `value` written at `path` (string segments for object keys, + * numbers for array indices). Containers along the path are cloned (and created when missing); + * everything off the path is shared with the input, which is never mutated. + */ +export function setValueAtPath( + values: Record, + path: Array, + value: unknown, +): Record { + const [head] = path; + // Unreachable by construction: a domain root is a named collection, so every recorded fill + // path starts with a field name. Loud rather than silent, because returning `values` here + // would leave a fill reported in `appliedDefaults` that `normalizedValues` does not contain. + if (typeof head !== 'string') { + throw new Error(`Cannot write a value at path '${path.join('.')}': the first segment must be a field name.`); + } + const record = { ...values }; + record[head] = setIn(record[head], path.slice(1), value); + return record; +} + /** * Converts a path array to a string representation, joining elements with dots and using brackets for numeric indices. * @param path diff --git a/src/validator.ts b/src/validator.ts index c11245a..04c31b6 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -2,6 +2,7 @@ import type { JSONSchema7 } from 'json-schema'; import type { FormanSchemaField, FormanValidationResult, + FormanNormalizedValidationResult, FormanExternalValidationResult, FormanSchemaExtendedNested, FormanSchemaExtendedOptions, @@ -29,6 +30,7 @@ import { IML_FILTER_OPERATORS, findValueInSelectOptions, pathToString, + setValueAtPath, stringToPath, } from './utils'; import { udttypeExpand } from './composites/udttype'; @@ -58,6 +60,8 @@ export interface ValidationContext { path: (string | number)[]; /** Unknown fields are not allowed when strict is true */ strict: boolean; + /** Fill declared defaults for omitted fields (see FormanValidationOptions.fillDefaults) */ + fillDefaults?: 'requiredOnly' | 'always'; suppressRequired?: boolean; registerOnly?: boolean; /** Maps domain names used in nested.domain to actual domain keys */ @@ -98,6 +102,8 @@ export interface DomainRoot { }>; /** Resolved schema fields collected during validation */ schemaFields: FormanSchemaField[]; + /** Defaults filled during validation (`options.fillDefaults`), with raw path segments */ + appliedDefaults: Array<{ path: Array; value: unknown }>; /** Whether the domain allows dynamic values (IML expressions, unresolved RPC select options) */ allowDynamicValues: boolean; } @@ -241,7 +247,7 @@ export async function validateFormanWithDomainsInternal( } >, options?: FormanValidationOptions, -): Promise { +): Promise { const errors: FormanValidationResult['errors'] = []; const warnings: FormanValidationResult['warnings'] = []; @@ -251,6 +257,7 @@ export async function validateFormanWithDomainsInternal( seenFields: new Set(), fieldStates: [], schemaFields: [], + appliedDefaults: [], allowDynamicValues: domains[domain]!.allowDynamicValues ?? options?.allowDynamicValues ?? false, validateFields: (fields: FormanSchemaField[], context: ValidationContext) => { return validateFormanValue( @@ -290,6 +297,7 @@ export async function validateFormanWithDomainsInternal( path: [], tail: [], strict: options?.strict === true, + fillDefaults: options?.fillDefaults, domainAliases: options?.domainAliases ?? {}, validateNestedFields: () => { throw new Error('Cannot validate nested fields without parent field.'); @@ -381,6 +389,23 @@ export async function validateFormanWithDomainsInternal( resolvedSchemas: options?.schemas ? Object.fromEntries(Object.keys(domains).map(domain => [domain, roots[domain]!.schemaFields])) : undefined, + normalizedValues: Object.fromEntries( + Object.keys(domains).map(domain => [ + domain, + (roots[domain]?.appliedDefaults ?? []).reduce( + (values, { path, value }) => setValueAtPath(values, path, value), + domains[domain]?.values ?? {}, + ), + ]), + ), + appliedDefaults: Object.keys(domains).flatMap( + domain => + roots[domain]?.appliedDefaults.map(({ path, value }) => ({ + domain, + path: path.join('.'), + value, + })) ?? [], + ), }; } @@ -434,6 +459,27 @@ async function validateFormanValue( // Normalize field type (handle prefixed types) const normalizedField = normalizeFormanFieldType(field); + // Same fillable predicate as BlueprintValidator's `useDefaults` (and the builder UI it cites), + // hence `=== undefined` rather than `== null`: an explicit `null` stays a provided value. A + // `null`/`''` default could not satisfy a required check, and on an optional field it is + // indistinguishable from the omission itself. + if ( + context.fillDefaults != null && + !context.suppressRequired && + (value === undefined || value === '') && + (context.fillDefaults === 'always' || normalizedField.required) + ) { + const fillable = normalizedField.default; + if (fillable != null && fillable !== '') { + // `default` is typed as a primitive, but schemas are JSON at source: a runtime object or + // array default is cloned so the result never aliases the schema's own default instance. + const filled = + isObject(fillable) || Array.isArray(fillable) ? structuredClone(fillable) : fillable; + value = filled; + context.roots[context.domain]!.appliedDefaults.push({ path: [...context.path], value: filled }); + } + } + if (normalizedField.required && !context.suppressRequired && (value == null || value === '')) { return { valid: false, diff --git a/test/chosen-option-nested.spec.ts b/test/chosen-option-nested.spec.ts index 4f0e6cb..1d4de5d 100644 --- a/test/chosen-option-nested.spec.ts +++ b/test/chosen-option-nested.spec.ts @@ -36,7 +36,7 @@ describe('Chosen option nested spec in field states', () => { }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -64,7 +64,7 @@ describe('Chosen option nested spec in field states', () => { { states: true }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -92,7 +92,7 @@ describe('Chosen option nested spec in field states', () => { { states: true }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -127,7 +127,7 @@ describe('Chosen option nested spec in field states', () => { { states: true }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -164,7 +164,7 @@ describe('Chosen option nested spec in field states', () => { // Dependent field states are siblings of the select state; the spec array on the // select's state must survive buildRestoreStructure untouched. - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], diff --git a/test/directives/filter.spec.ts b/test/directives/filter.spec.ts index 3a1faed..5b41c80 100644 --- a/test/directives/filter.spec.ts +++ b/test/directives/filter.spec.ts @@ -238,7 +238,7 @@ describe('Filter Type with Configurable Options and Operators', () => { }; const result = await validateForman(value, [formanSchema]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should validate filter with unary operator', async () => { @@ -252,7 +252,7 @@ describe('Filter Type with Configurable Options and Operators', () => { }; const result = await validateForman(value, [formanSchema]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should validate filter with multiple conditions', async () => { @@ -271,7 +271,7 @@ describe('Filter Type with Configurable Options and Operators', () => { }; const result = await validateForman(value, [formanSchema]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); }); diff --git a/test/directives/nested.spec.ts b/test/directives/nested.spec.ts index 02cec75..849420b 100644 --- a/test/directives/nested.spec.ts +++ b/test/directives/nested.spec.ts @@ -124,7 +124,7 @@ describe('Nested', () => { }, }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], diff --git a/test/directives/rpc.spec.ts b/test/directives/rpc.spec.ts index 82c3b9c..5809ddc 100644 --- a/test/directives/rpc.spec.ts +++ b/test/directives/rpc.spec.ts @@ -452,7 +452,7 @@ describe('RPC', () => { ]); }, }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [ @@ -480,7 +480,7 @@ describe('RPC', () => { return Promise.resolve([{ value: 'a', label: 'Option A' }]); }, }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [ diff --git a/test/fill-defaults.spec.ts b/test/fill-defaults.spec.ts new file mode 100644 index 0000000..51dedd9 --- /dev/null +++ b/test/fill-defaults.spec.ts @@ -0,0 +1,386 @@ +import { describe, expect, it } from '@jest/globals'; +import { validateForman, validateFormanWithDomains } from '../src/index.js'; +import type { FormanSchemaField } from '../src/index.js'; + +describe('fillDefaults: requiredOnly', () => { + // A required toggle with a declared default and a required field revealed only when it is on. + const fallbackToggle: FormanSchemaField[] = [ + { + name: 'fallbackEnabled', + type: 'boolean', + required: true, + default: false, + nested: [{ name: 'fallbackConnectionId', type: 'text', required: true }], + }, + ]; + + it('fills an omitted required field from its declared default and reports it', async () => { + const result = await validateForman({}, fallbackToggle, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.normalizedValues).toEqual({ default: { fallbackEnabled: false } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'fallbackEnabled', value: false }]); + }); + + it('fills defaults revealed by a default it just filled, in the same pass', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'toggle', + type: 'boolean', + required: true, + default: true, + nested: [{ name: 'label', type: 'text', required: true, default: 'fallback label' }], + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { toggle: true, label: 'fallback label' } }); + expect(result.appliedDefaults).toEqual([ + { domain: 'default', path: 'toggle', value: true }, + { domain: 'default', path: 'label', value: 'fallback label' }, + ]); + }); + + it('reports requirements armed by a filled default alongside the fill', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'compactionEnabled', + type: 'boolean', + required: true, + default: true, + nested: [{ name: 'compactionThreshold', type: 'number', required: true }], + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual([ + { domain: 'default', path: 'compactionThreshold', message: 'Field is mandatory.' }, + ]); + expect(result.normalizedValues).toEqual({ default: { compactionEnabled: true } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'compactionEnabled', value: true }]); + }); + + it('never overwrites a provided value, falsy included', async () => { + const schema: FormanSchemaField[] = [ + { name: 'retries', type: 'number', required: true, default: 3 }, + { name: 'verbose', type: 'boolean', required: true, default: true }, + ]; + const result = await validateForman({ retries: 0, verbose: false }, schema, { + strict: true, + fillDefaults: 'requiredOnly', + }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { retries: 0, verbose: false } }); + expect(result.appliedDefaults).toEqual([]); + }); + + it('fills over an explicit empty string, matching the BlueprintValidator predicate', async () => { + const schema: FormanSchemaField[] = [{ name: 'mode', type: 'text', required: true, default: 'select' }]; + const input = { mode: '' }; + const result = await validateForman(input, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { mode: 'select' } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'mode', value: 'select' }]); + expect(input).toEqual({ mode: '' }); + }); + + it('still fails an explicit null, which is a provided value', async () => { + const schema: FormanSchemaField[] = [{ name: 'mode', type: 'text', required: true, default: 'select' }]; + const result = await validateForman({ mode: null }, schema, { + strict: true, + fillDefaults: 'requiredOnly', + }); + expect(result.errors).toEqual([{ domain: 'default', path: 'mode', message: 'Field is mandatory.' }]); + expect(result.appliedDefaults).toEqual([]); + }); + + it('treats null and empty-string defaults as no default', async () => { + const schema: FormanSchemaField[] = [ + { name: 'source', type: 'text', required: true, default: null }, + { name: 'label', type: 'text', required: true, default: '' }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.errors).toEqual([ + { domain: 'default', path: 'source', message: 'Field is mandatory.' }, + { domain: 'default', path: 'label', message: 'Field is mandatory.' }, + ]); + expect(result.appliedDefaults).toEqual([]); + }); + + it('never fills optional fields', async () => { + const schema: FormanSchemaField[] = [{ name: 'reasoningEffort', type: 'text', default: 'low' }]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: {} }); + expect(result.appliedDefaults).toEqual([]); + }); + + it('fills defaults on fields injected by a remote-resolved spec', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'model', + type: 'select', + required: true, + options: [{ value: 'gpt', nested: 'rpc://modelParams' }], + }, + ]; + const result = await validateForman({ model: 'gpt' }, schema, { + strict: true, + fillDefaults: 'requiredOnly', + resolveRemote: async path => + path === 'rpc://modelParams' + ? [{ name: 'fallbackEnabled', type: 'boolean', required: true, default: false }] + : [], + }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { model: 'gpt', fallbackEnabled: false } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'fallbackEnabled', value: false }]); + }); + + it('does not fill under a branch its own filled default left inactive', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'advanced', + type: 'boolean', + required: true, + default: false, + nested: [{ name: 'level', type: 'text', required: true, default: 'high' }], + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { advanced: false } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'advanced', value: false }]); + }); + + it('conditions a reversedNested branch on the filled default', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'simple', + type: 'boolean', + required: true, + default: true, + reversedNested: true, + nested: [{ name: 'advancedConfig', type: 'text', required: true }], + }, + ]; + // With reversedNested the branch applies on `false`, so the filled `true` leaves it + // inactive and its requirement must not fire. + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { simple: true } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'simple', value: true }]); + }); + + it('routes a filled two-branch boolean to the matching branch', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'enabled', + type: 'boolean', + required: true, + default: false, + nested: { + true: [{ name: 'target', type: 'text', required: true }], + false: [{ name: 'reason', type: 'text', required: true, default: 'disabled by default' }], + }, + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { enabled: false, reason: 'disabled by default' } }); + expect(result.appliedDefaults).toEqual([ + { domain: 'default', path: 'enabled', value: false }, + { domain: 'default', path: 'reason', value: 'disabled by default' }, + ]); + }); + + it('fills inside collections and array items at the right path', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'rows', + type: 'array', + spec: [ + { name: 'title', type: 'text', required: true }, + { name: 'mode', type: 'text', required: true, default: 'select' }, + ], + }, + ]; + const result = await validateForman({ rows: [{ title: 'first' }, { title: 'second' }] }, schema, { + strict: true, + fillDefaults: 'requiredOnly', + }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ + default: { + rows: [ + { title: 'first', mode: 'select' }, + { title: 'second', mode: 'select' }, + ], + }, + }); + expect(result.appliedDefaults).toEqual([ + { domain: 'default', path: 'rows.0.mode', value: 'select' }, + { domain: 'default', path: 'rows.1.mode', value: 'select' }, + ]); + }); + + it('fills each domain independently and never mutates the input values', async () => { + const parameterValues = {}; + const expectValues = { message: 'hi' }; + const result = await validateFormanWithDomains( + { + parameters: { + values: parameterValues, + schema: [{ name: 'kind', type: 'text', required: true, default: 'basic' }], + }, + expect: { + values: expectValues, + schema: [ + { name: 'message', type: 'text', required: true }, + { name: 'aiCompactionEnabled', type: 'boolean', required: true, default: true }, + ], + }, + }, + { strict: true, fillDefaults: 'requiredOnly' }, + ); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ + parameters: { kind: 'basic' }, + expect: { message: 'hi', aiCompactionEnabled: true }, + }); + expect(result.appliedDefaults).toEqual([ + { domain: 'parameters', path: 'kind', value: 'basic' }, + { domain: 'expect', path: 'aiCompactionEnabled', value: true }, + ]); + expect(parameterValues).toEqual({}); + expect(expectValues).toEqual({ message: 'hi' }); + }); + + it('records a cross-domain fill under the domain that owns the field', async () => { + const result = await validateFormanWithDomains( + { + source: { + values: { host: 'localhost' }, + schema: [ + { + name: 'host', + type: 'text', + nested: { + store: [{ name: 'port', type: 'number', required: true, default: 8080 }], + domain: 'default', + }, + }, + ], + }, + default: { values: {}, schema: [] }, + }, + { strict: true, fillDefaults: 'requiredOnly' }, + ); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ source: { host: 'localhost' }, default: { port: 8080 } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'port', value: 8080 }]); + }); + + it('reports a filled default that fails its own validation, with the value it tried', async () => { + const schema: FormanSchemaField[] = [ + { name: 'level', type: 'text', required: true, default: 'extreme', validate: { enum: ['low', 'high'] } }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual([ + { domain: 'default', path: 'level', message: 'Value must be one of the following: low, high' }, + ]); + expect(result.normalizedValues).toEqual({ default: { level: 'extreme' } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'level', value: 'extreme' }]); + }); + + it('clones a runtime object default instead of aliasing the schema instance', async () => { + // `default` is typed as a primitive, but JSON-sourced schemas can carry object defaults. + const objectDefault = { depth: 1 }; + const schema: FormanSchemaField[] = [ + { + name: 'options', + type: 'collection', + required: true, + default: objectDefault as unknown as FormanSchemaField['default'], + spec: [{ name: 'depth', type: 'number' }], + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'requiredOnly' }); + expect(result.valid).toBe(true); + const filled = result.normalizedValues?.default?.options; + expect(filled).toEqual({ depth: 1 }); + expect(filled).not.toBe(objectDefault); + expect(result.appliedDefaults?.[0]?.value).not.toBe(objectDefault); + }); + + it('changes no validation outcome when the option is off', async () => { + const result = await validateForman({}, fallbackToggle, { strict: true }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual([{ domain: 'default', path: 'fallbackEnabled', message: 'Field is mandatory.' }]); + expect(result.appliedDefaults).toEqual([]); + }); +}); + +describe('fillDefaults: always', () => { + it('fills omitted optional fields too', async () => { + const schema: FormanSchemaField[] = [ + { name: 'kind', type: 'text', required: true, default: 'basic' }, + { name: 'reasoningEffort', type: 'text', default: 'low' }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'always' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { kind: 'basic', reasoningEffort: 'low' } }); + expect(result.appliedDefaults).toEqual([ + { domain: 'default', path: 'kind', value: 'basic' }, + { domain: 'default', path: 'reasoningEffort', value: 'low' }, + ]); + }); + + // Pins the one case where a fill overrides caller intent: `''` counts as an omission (the + // BlueprintValidator predicate), so under `'always'` a deliberately cleared optional field + // comes back with its default. Documented in the README and the `fillDefaults` JSDoc. + it('fills a deliberately cleared optional field, per the blueprint predicate', async () => { + const schema: FormanSchemaField[] = [{ name: 'note', type: 'text', default: 'Hello' }]; + const cleared = { note: '' }; + expect((await validateForman(cleared, schema, { fillDefaults: 'always' })).normalizedValues).toEqual({ + default: { note: 'Hello' }, + }); + // `'requiredOnly'` leaves it alone, which is the escape hatch the docs point at. + expect((await validateForman(cleared, schema, { fillDefaults: 'requiredOnly' })).normalizedValues).toEqual({ + default: { note: '' }, + }); + }); + + it('does not fill optional defaults under a branch left inactive', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'advanced', + type: 'boolean', + required: true, + default: false, + nested: [{ name: 'level', type: 'text', default: 'high' }], + }, + ]; + const result = await validateForman({}, schema, { strict: true, fillDefaults: 'always' }); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ default: { advanced: false } }); + expect(result.appliedDefaults).toEqual([{ domain: 'default', path: 'advanced', value: false }]); + }); +}); + +describe('normalizedValues without fillDefaults', () => { + it('passes the input values through, per domain, so the caller-side pattern never changes', async () => { + const parameterValues = { kind: 'basic' }; + const result = await validateFormanWithDomains( + { + parameters: { values: parameterValues, schema: [{ name: 'kind', type: 'text', required: true }] }, + expect: { values: {}, schema: [{ name: 'message', type: 'text' }] }, + }, + { strict: true }, + ); + expect(result.valid).toBe(true); + expect(result.normalizedValues).toEqual({ parameters: { kind: 'basic' }, expect: {} }); + expect(result.appliedDefaults).toEqual([]); + }); +}); diff --git a/test/restore.spec.ts b/test/restore.spec.ts index 1438ab3..67304aa 100644 --- a/test/restore.spec.ts +++ b/test/restore.spec.ts @@ -18,7 +18,7 @@ describe('Restore state for IML-mapped fields', () => { { states: true, allowDynamicValues: true }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -37,7 +37,7 @@ describe('Restore state for IML-mapped fields', () => { { states: true, allowDynamicValues: true }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -55,7 +55,7 @@ describe('Restore state for IML-mapped fields', () => { allowDynamicValues: true, }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -73,7 +73,7 @@ describe('Restore state for IML-mapped fields', () => { allowDynamicValues: true, }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -91,7 +91,7 @@ describe('Restore state for IML-mapped fields', () => { allowDynamicValues: true, }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -119,7 +119,7 @@ describe('Restore state for IML-mapped fields', () => { }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [ @@ -153,7 +153,7 @@ describe('Restore state for IML-mapped fields', () => { }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [ @@ -187,7 +187,7 @@ describe('Restore state for IML-mapped fields', () => { { states: true }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], diff --git a/test/utils.spec.ts b/test/utils.spec.ts index aab8d56..1b09027 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from '@jest/globals'; import { noEmpty, + setValueAtPath, isObject, isOptionGroup, containsIMLExpression, @@ -564,3 +565,20 @@ describe('Utils Functions', () => { }); }); }); + +describe('setValueAtPath', () => { + it('writes copy-on-write, sharing subtrees it did not touch', () => { + const input = { kept: { deep: 1 }, rows: [{ mode: 'a' }, { mode: 'b' }] }; + const out = setValueAtPath(input, ['rows', 1, 'mode'], 'filled'); + expect(out).toEqual({ kept: { deep: 1 }, rows: [{ mode: 'a' }, { mode: 'filled' }] }); + expect(input.rows[1]!.mode).toBe('b'); + expect(out.kept).toBe(input.kept); + }); + + it('throws rather than silently declining a path it cannot write', () => { + // Unreachable by construction, but a silent no-op would report a fill in + // `appliedDefaults` that `normalizedValues` does not contain. + expect(() => setValueAtPath({ a: 1 }, [], 'X')).toThrow(/must be a field name/); + expect(() => setValueAtPath({ a: 1 }, [0], 'X')).toThrow(/must be a field name/); + }); +}); diff --git a/test/validator-comprehensive.spec.ts b/test/validator-comprehensive.spec.ts index 04ea212..0f92954 100644 --- a/test/validator-comprehensive.spec.ts +++ b/test/validator-comprehensive.spec.ts @@ -52,7 +52,7 @@ describe('Forman Schema Comprehensive Coverage', () => { { name: 'checkbox', type: 'checkbox' }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -74,7 +74,7 @@ describe('Forman Schema Comprehensive Coverage', () => { { name: 'email', type: 'email' }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -116,7 +116,7 @@ describe('Forman Schema Comprehensive Coverage', () => { { name: 'invalidFiles', type: 'upload' }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -162,7 +162,7 @@ describe('Forman Schema Comprehensive Coverage', () => { { name: 'invalidFlatFilter', type: 'filter', logic: 'or' as const }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ errors: [ { domain: 'default', @@ -225,7 +225,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -254,7 +254,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -294,7 +294,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, ]; - expect(await validateForman(formanValue, formanSchema, { states: true })).toEqual({ + expect(await validateForman(formanValue, formanSchema, { states: true })).toMatchObject({ valid: true, errors: [], warnings: [], @@ -378,7 +378,7 @@ describe('Forman Schema Comprehensive Coverage', () => { throw new Error(`Unknown path: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -421,7 +421,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -453,7 +453,7 @@ describe('Forman Schema Comprehensive Coverage', () => { const wrappedValue = { data: formanValue }; - expect(await validateForman(wrappedValue, formanSchema)).toEqual({ + expect(await validateForman(wrappedValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -512,7 +512,7 @@ describe('Forman Schema Comprehensive Coverage', () => { throw new Error(`Unknown path: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -559,7 +559,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, }; - expect(await validateFormanWithDomains(domains)).toEqual({ + expect(await validateFormanWithDomains(domains)).toMatchObject({ valid: false, errors: [ { @@ -607,7 +607,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -640,7 +640,7 @@ describe('Forman Schema Comprehensive Coverage', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -684,7 +684,7 @@ describe('Forman Schema Comprehensive Coverage', () => { ]; // The specific option's nested should override global nested - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -695,17 +695,17 @@ describe('Forman Schema Comprehensive Coverage', () => { describe('Checkbox Type', () => { it('should validate checkbox with true value', async () => { const result = await validateForman({ enabled: true }, [{ name: 'enabled', type: 'checkbox' }]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should validate checkbox with false value', async () => { const result = await validateForman({ disabled: false }, [{ name: 'disabled', type: 'checkbox' }]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should reject non-boolean values for checkbox', async () => { const result = await validateForman({ enabled: 'true' }, [{ name: 'enabled', type: 'checkbox' }]); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: false, errors: [ { @@ -720,7 +720,7 @@ describe('Forman Schema Comprehensive Coverage', () => { it('should reject number values for checkbox', async () => { const result = await validateForman({ enabled: 1 }, [{ name: 'enabled', type: 'checkbox' }]); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: false, errors: [ { @@ -737,7 +737,7 @@ describe('Forman Schema Comprehensive Coverage', () => { const result = await validateForman({ enabled: null }, [ { name: 'enabled', type: 'checkbox', required: true }, ]); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: false, errors: [ { @@ -752,12 +752,12 @@ describe('Forman Schema Comprehensive Coverage', () => { it('should allow null for optional checkbox', async () => { const result = await validateForman({ enabled: null }, [{ name: 'enabled', type: 'checkbox' }]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should support default value for checkbox', async () => { const result = await validateForman({}, [{ name: 'enabled', type: 'checkbox', default: true }]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should validate checkbox in nested objects', async () => { @@ -768,7 +768,7 @@ describe('Forman Schema Comprehensive Coverage', () => { spec: [{ name: 'notifications', type: 'checkbox' }], }, ]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should validate checkbox in arrays', async () => { @@ -779,7 +779,7 @@ describe('Forman Schema Comprehensive Coverage', () => { spec: { type: 'collection', spec: [{ name: 'active', type: 'checkbox' }] }, }, ]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); }); @@ -789,7 +789,7 @@ describe('Forman Schema Comprehensive Coverage', () => { // from `normalizeFormanFieldType`. Same production data that broke schema conversion. const result = await validateForman({ f: 'x' }, [{ name: 'f' } as never]); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: false, errors: [{ domain: 'default', path: 'f', message: 'Field type is required.' }], warnings: [], @@ -801,13 +801,13 @@ describe('Forman Schema Comprehensive Coverage', () => { // JSON type, so the type check is skipped rather than failing. The converter now matches. const result = await validateForman({ f: 'x' }, [{ name: 'f', type: 'bogusType' }]); - expect(result).toEqual({ valid: true, errors: [], warnings: [] }); + expect(result).toMatchObject({ valid: true, errors: [], warnings: [] }); }); it('should still enforce required on a field with an unknown type', async () => { const result = await validateForman({ f: '' }, [{ name: 'f', type: 'bogusType', required: true }]); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: false, errors: [{ domain: 'default', path: 'f', message: 'Field is mandatory.' }], warnings: [], diff --git a/test/validator-extended.spec.ts b/test/validator-extended.spec.ts index 7a3674f..21a2246 100644 --- a/test/validator-extended.spec.ts +++ b/test/validator-extended.spec.ts @@ -26,7 +26,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -56,7 +56,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -101,7 +101,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -142,7 +142,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -187,7 +187,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -240,7 +240,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -296,7 +296,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -371,7 +371,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema, { allowDynamicValues: true })).toEqual({ + expect(await validateForman(formanValue, formanSchema, { allowDynamicValues: true })).toMatchObject({ valid: false, errors: [ { @@ -414,7 +414,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -453,7 +453,11 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ valid: true, errors: [], warnings: [] }); + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ + valid: true, + errors: [], + warnings: [], + }); }); it('should not attempt to validate or coerce values for visual types', async () => { @@ -479,7 +483,11 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ valid: true, errors: [], warnings: [] }); + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ + valid: true, + errors: [], + warnings: [], + }); }); }); @@ -528,7 +536,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -587,7 +595,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -621,7 +629,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error('Network error'); }, }), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ { @@ -658,7 +666,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error('Nested resource error'); }, }), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ { @@ -685,7 +693,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -750,7 +758,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -793,7 +801,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -827,7 +835,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -852,7 +860,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -875,7 +883,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -924,7 +932,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error(`Unknown resource: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ { @@ -1024,7 +1032,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1052,7 +1060,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1077,7 +1085,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1112,7 +1120,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1145,7 +1153,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1189,7 +1197,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error(`Unknown resource: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1217,7 +1225,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error(`Unknown resource: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1245,7 +1253,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error(`Unknown resource: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1290,7 +1298,7 @@ describe('Forman Schema Extended Validation', () => { }, }; - expect(await validateFormanWithDomains(domains)).toEqual({ + expect(await validateFormanWithDomains(domains)).toMatchObject({ valid: false, errors: [ { @@ -1321,7 +1329,7 @@ describe('Forman Schema Extended Validation', () => { }, }; - expect(await validateFormanWithDomains(domains)).toEqual({ + expect(await validateFormanWithDomains(domains)).toMatchObject({ valid: false, errors: [ { @@ -1359,7 +1367,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1402,7 +1410,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1428,7 +1436,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1450,7 +1458,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1490,7 +1498,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1530,7 +1538,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1562,7 +1570,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1618,7 +1626,7 @@ describe('Forman Schema Extended Validation', () => { return []; }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [ @@ -1650,7 +1658,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1703,7 +1711,7 @@ describe('Forman Schema Extended Validation', () => { return []; }, }), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ // emptyPath: '' is valid for non-required file fields (means "no selection") @@ -1735,7 +1743,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1768,7 +1776,7 @@ describe('Forman Schema Extended Validation', () => { return []; }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [ @@ -1802,7 +1810,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1830,7 +1838,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -1862,7 +1870,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error('Connection timeout'); }, }), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ { @@ -1906,7 +1914,7 @@ describe('Forman Schema Extended Validation', () => { return []; }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1944,7 +1952,7 @@ describe('Forman Schema Extended Validation', () => { return []; }, }), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -1979,7 +1987,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: false, errors: [ { @@ -2015,7 +2023,7 @@ describe('Forman Schema Extended Validation', () => { }, ]; - expect(await validateForman(formanValue, formanSchema)).toEqual({ + expect(await validateForman(formanValue, formanSchema)).toMatchObject({ valid: true, errors: [], warnings: [], @@ -2139,7 +2147,7 @@ describe('Forman Schema Extended Validation', () => { throw new Error(`Unknown resource: ${path}`); }, }), - ).toEqual({ + ).toMatchObject({ errors: [], warnings: [], states: { @@ -2216,7 +2224,7 @@ describe('Forman Schema Extended Validation', () => { { resolveRemote }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -2258,7 +2266,7 @@ describe('Forman Schema Extended Validation', () => { const result = await validateForman({ choice: '2026-04-10T11:40:11.000Z' }, schema, { resolveRemote }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -2285,7 +2293,7 @@ describe('Forman Schema Extended Validation', () => { { resolveRemote }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], @@ -2313,7 +2321,7 @@ describe('Forman Schema Extended Validation', () => { resolveRemote: async () => standardOptions, }); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [], diff --git a/test/validator-manifest.spec.ts b/test/validator-manifest.spec.ts index 7635ae7..78050ea 100644 --- a/test/validator-manifest.spec.ts +++ b/test/validator-manifest.spec.ts @@ -41,7 +41,7 @@ describe('Forman Schema Manifest Validation', () => { schema: googleSheetsAddRowMock.expect, }, }), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ { @@ -76,7 +76,7 @@ describe('Forman Schema Manifest Validation', () => { }, }, ), - ).toEqual({ + ).toMatchObject({ valid: false, errors: [ { @@ -171,7 +171,7 @@ describe('Forman Schema Manifest Validation', () => { }, }, ), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -280,7 +280,7 @@ describe('Forman Schema Manifest Validation', () => { }, }, ), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -353,7 +353,7 @@ describe('Forman Schema Manifest Validation', () => { }, }, ), - ).toEqual({ + ).toMatchObject({ valid: true, errors: [], warnings: [], @@ -432,7 +432,7 @@ describe('Forman Schema Manifest Validation', () => { }, }, ); - expect(result).toEqual({ + expect(result).toMatchObject({ valid: true, errors: [], warnings: [],