blob: ad82b45b9f814149795fb2adf8194d65dd7f1e87 [file]
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
// Unit tests for the schema projector + validators.
// Mocha test; `describe`/`it` are mocha globals (run via the Bazel mocha target).
// The completeness test is the "compare input to output independent of
// generation" gate — it re-derives expected methods from the raw AST, not the model.
import assert from 'node:assert/strict'
import { projectSchema, checkSchema, checkCompleteness } from './project_bidi_schema.mjs'
const lit = (v) => ({ Type: 'literal', Value: v, Unwrapped: false })
const ref = (v) => ({ Type: 'group', Value: v, Unwrapped: false })
const field = (name, type, occ = { n: 1, m: 1 }) => ({ Name: name, Occurrence: occ, Type: type, Comments: [] })
const group = (name, props) => ({ Type: 'group', Name: name, Properties: props, IsChoiceAddition: false, Comments: [] })
const leaf = (cddlName, method, paramsRef) =>
group(cddlName, [field('method', [lit(method)]), field('params', [ref(paramsRef)])])
// A tiny but representative AST + model.
const AST = [
leaf('network.SetCacheBehavior', 'network.setCacheBehavior', 'network.SetCacheBehaviorParameters'),
group('network.SetCacheBehaviorParameters', [field('cacheBehavior', [lit('default'), lit('bypass')])]),
group('session.Caps', [field('extra', { Type: 'group', Name: '', Properties: [field('webSocketUrl', ['bool'])] })]),
group('x.OpenMap', [field('text', ['any'], { n: 0, m: null })]),
]
const MODEL = {
network: {
commands: [
{
method: 'network.setCacheBehavior',
name: 'setCacheBehavior',
params: 'network.SetCacheBehaviorParameters',
result: null,
},
],
events: [],
},
}
describe('projectSchema', () => {
const schema = projectSchema(AST, MODEL)
it('emits a clean enum for an inline string-literal union, tagged with its origin', () => {
assert.deepEqual(schema.types['network.SetCacheBehaviorParametersCacheBehavior'], {
kind: 'enum',
values: ['default', 'bypass'],
synthetic: true,
owner: 'network.SetCacheBehaviorParameters',
label: 'CacheBehavior',
})
assert.deepEqual(schema.types['network.SetCacheBehaviorParameters'].fields[0].type, {
ref: 'network.SetCacheBehaviorParametersCacheBehavior',
})
})
it('hoists an inline record so the field is a plain ref (no inline records), tagged with its origin', () => {
assert.deepEqual(schema.types['session.Caps'].fields[0].type, { ref: 'session.CapsExtra' })
const extra = schema.types['session.CapsExtra']
assert.ok(extra, 'inline record was hoisted to a named type')
assert.equal(extra.synthetic, true)
assert.equal(extra.owner, 'session.Caps')
assert.equal(extra.label, 'Extra')
})
it('marks `* text => any` extensible instead of emitting a phantom field', () => {
const open = schema.types['x.OpenMap']
assert.equal(open.extensible, true)
assert.equal(open.fields.length, 0)
})
it('passes both validators on a well-formed schema', () => {
assert.deepEqual(checkSchema(schema), [])
assert.deepEqual(checkCompleteness(AST, schema), [])
})
it('recovers command params from the envelope when the model dropped an inline params object', () => {
// The command declares an inline `params: { count }` (not a named ref); the model
// builder records params: null, but the normalizer hoists it to x.FooCommandParams
// and the envelope record points at it — so the command entry must surface it.
const ast = [
group('x.FooCommand', [
field('method', [lit('x.foo')]),
field('params', { Type: 'group', Name: '', Properties: [field('count', ['uint'])] }),
]),
]
const model = { x: { commands: [{ method: 'x.foo', name: 'foo', params: null, result: null }], events: [] } }
const out = projectSchema(ast, model)
assert.deepEqual(out.commands[0].params, { ref: 'x.FooCommandParams' })
assert.deepEqual(checkSchema(out), [])
})
})
describe('projectType (list / union / alias defs)', () => {
const anon = (v) => ({ Name: '', Occurrence: { n: 1, m: 1 }, Type: ref(v), Comments: [] })
const ast = [
{
Type: 'array',
Name: 'x.Items',
Values: [{ Name: '', Occurrence: { n: 0, m: null }, Type: [ref('x.Item')], Comments: [] }],
},
group('x.Item', [field('a', ['text'])]),
group('x.Other', [field('b', ['text'])]),
{
Type: 'group',
Name: 'x.Choice',
Properties: [[anon('x.Item'), anon('x.Other')]],
IsChoiceAddition: false,
Comments: [],
},
{ Type: 'group', Name: 'x.FooEvent', Properties: [anon('x.Item')], IsChoiceAddition: false, Comments: [] },
]
const schema = projectSchema(ast, {})
it('projects a top-level array def as an alias to a list (keeps the element type)', () => {
assert.deepEqual(schema.types['x.Items'], { kind: 'alias', type: { list: { ref: 'x.Item' } } })
})
it('fails closed (unknown, not null) when an array element type is missing', () => {
const s = projectSchema([{ Type: 'array', Name: 'x.Bad', Values: [] }], {})
assert.deepEqual(s.types['x.Bad'], { kind: 'alias', type: { list: { primitive: 'unknown' } } })
assert.ok(
checkSchema(s).some((e) => /unknown primitive/.test(e)),
'a missing element type must trip the unknown-primitive guard',
)
})
it('projects a multi-member choice group as a union with a structural selector', () => {
assert.deepEqual(schema.types['x.Choice'], {
kind: 'union',
variants: ['x.Item', 'x.Other'],
selector: {
ordered: [
{ ref: 'x.Item', requires: ['a'] },
{ ref: 'x.Other', requires: ['b'] },
],
},
})
})
it('projects a single-member dispatch choice group as an alias to its ref', () => {
assert.deepEqual(schema.types['x.FooEvent'], { kind: 'alias', type: { ref: 'x.Item' } })
})
it('projects an integer range as integer and a float range as number', () => {
const s = projectSchema(
[
{
Type: 'variable',
Name: 'x.U',
PropertyType: [{ Type: 'range', Value: { Min: { Value: 0 }, Max: { Value: 100 } } }],
},
{
Type: 'variable',
Name: 'x.F',
PropertyType: [{ Type: 'range', Value: { Min: { Value: 0.1 }, Max: { Value: 2 } } }],
},
],
{},
)
assert.deepEqual(s.types['x.U'], { kind: 'alias', type: { primitive: 'integer' } })
assert.deepEqual(s.types['x.F'], { kind: 'alias', type: { primitive: 'number' } })
})
it('unwraps a control-operator (.default / .ge) wrapped field type to its inner type', () => {
const wrapped = {
Name: 'n',
Occurrence: { n: 1, m: 1 },
Type: [{ Type: ref('x.Inner'), Operator: { Type: 'default', Value: lit('a') } }],
Comments: [],
}
const s = projectSchema([group('x.R', [wrapped]), group('x.Inner', [field('z', ['text'])])], {})
assert.deepEqual(s.types['x.R'].fields[0].type, { ref: 'x.Inner' })
})
it('promotes a union whose arms are inline groups wrapping refs (LocalValue date/regexp arms)', () => {
const inlineArm = {
Type: 'group',
Name: '',
Properties: [{ Name: '', Occurrence: { n: 1, m: 1 }, Type: [ref('x.B')], Comments: [] }],
}
const s = projectSchema(
[
{ Type: 'variable', Name: 'x.U', PropertyType: [ref('x.A'), inlineArm] },
group('x.A', [field('a', ['text'])]),
group('x.B', [field('b', ['text'])]),
],
{},
)
// The inline-group arm resolves to x.B, so x.U is a first-class union (not an
// alias-to-union) and both arms are reachable variants.
assert.deepEqual(s.types['x.U'].kind, 'union')
assert.deepEqual(s.types['x.U'].variants, ['x.A', 'x.B'])
})
})
describe('unionSelector', () => {
const recAst = (name, typeConst, extra = []) => ({
Type: 'group',
Name: name,
IsChoiceAddition: false,
Comments: [],
Properties: [{ Name: 'type', Occurrence: { n: 1, m: 1 }, Type: [lit(typeConst)], Comments: [] }, ...extra],
})
const union = (name, refs) => ({
Type: 'variable',
Name: name,
IsChoiceAddition: false,
Comments: [],
PropertyType: refs.map(ref),
})
it('discriminates on a constant key, flattening a tagged sub-union and recording an untyped default', () => {
// x.Value = ( x.Prim / x.Date / x.Reference ); x.Prim is itself type-tagged,
// x.Reference has no `type` (dispatched structurally) → it is the default.
const ast = [
union('x.Value', ['x.Prim', 'x.Date', 'x.Reference']),
union('x.Prim', ['x.StringValue', 'x.NullValue']),
recAst('x.StringValue', 'string'),
recAst('x.NullValue', 'null'),
recAst('x.Date', 'date'),
group('x.Reference', [field('refId', ['text'])]),
]
const s = projectSchema(ast, {})
const sel = s.types['x.Value'].selector
assert.equal(sel.by, 'type')
assert.equal(sel.default, 'x.Reference')
// NullValue's `type` is the string literal "null" (its discriminator tag), so it
// dispatches on the string "null" — not the JSON null type.
assert.deepEqual(s.types['x.NullValue'].fields[0].type, { const: 'null' })
assert.deepEqual(
new Map(sel.variants.map((v) => [JSON.stringify(v.value), v.ref])),
new Map([
['"string"', 'x.StringValue'],
['"null"', 'x.NullValue'],
['"date"', 'x.Date'],
]),
)
})
it('uses an open base-type arm as the discriminator default (log.Entry shape)', () => {
const ast = [
union('x.Entry', ['x.Generic', 'x.Console']),
group('x.Generic', [field('type', ['text'])]), // open `type` → catch-all
recAst('x.Console', 'console'),
]
const sel = projectSchema(ast, {}).types['x.Entry'].selector
assert.equal(sel.by, 'type')
assert.equal(sel.default, 'x.Generic')
assert.deepEqual(sel.variants, [{ value: 'console', ref: 'x.Console' }])
})
it('falls back to an ordered structural selector when no shared discriminator exists', () => {
const ast = [
union('x.Ref', ['x.Shared', 'x.Remote']),
group('x.Shared', [
field('sharedId', ['text']),
{ Name: 'handle', Occurrence: { n: 0, m: 1 }, Type: ['text'], Comments: [] },
]),
group('x.Remote', [field('handle', ['text'])]),
]
const sel = projectSchema(ast, {}).types['x.Ref'].selector
assert.deepEqual(sel, {
ordered: [
{ ref: 'x.Shared', requires: ['sharedId'] },
{ ref: 'x.Remote', requires: ['handle'] },
],
})
})
// The response envelope is a record pairing a `result` union with the request `id`.
const envelope = (resultRef) => group('x.CommandResponse', [field('id', ['uint']), field('result', [ref(resultRef)])])
it('marks an undispatchable result-grouping union (reached via `result`) as correlated', () => {
// CommandResponse.result -> x.ResultData, a union of result records that cannot
// be told apart from the payload (no required fields): it is dispatched by
// request id, so it carries no selector.
const ast = [
envelope('x.ResultData'),
union('x.ResultData', ['x.FooResult', 'x.BarResult']),
group('x.FooResult', []),
group('x.BarResult', []),
]
assert.deepEqual(projectSchema(ast, {}).types['x.ResultData'].selector, { correlated: true })
})
it('keeps a structurally-dispatchable result reached via `result` (not correlated)', () => {
// Distinguishable result records (each has its own required field) stay
// payload-dispatched even though they are reached through the envelope.
const ast = [
envelope('x.ResultData'),
union('x.ResultData', ['x.FooResult', 'x.BarResult']),
group('x.FooResult', [field('foo', ['text'])]),
group('x.BarResult', [field('bar', ['text'])]),
]
assert.deepEqual(projectSchema(ast, {}).types['x.ResultData'].selector, {
ordered: [
{ ref: 'x.FooResult', requires: ['foo'] },
{ ref: 'x.BarResult', requires: ['bar'] },
],
})
})
it('does not mark a discriminated result reached via `result` as correlated (e.g. EvaluateResult)', () => {
const ast = [
envelope('x.EvalResult'),
union('x.EvalResult', ['x.Success', 'x.Failure']),
recAst('x.Success', 'success'),
recAst('x.Failure', 'exception'),
]
const sel = projectSchema(ast, {}).types['x.EvalResult'].selector
assert.equal(sel.by, 'type') // payload-dispatched, selector preserved
})
it('does not correlate a union reached via a non-envelope `result` field (no request id)', () => {
// A plain payload type has a `result` field but no request `id`, so it is not the
// response envelope; its union must keep its payload selector, not be correlated.
const ast = [
group('x.EvaluateResultSuccess', [field('result', [ref('x.ResultData')]), field('realm', ['text'])]),
union('x.ResultData', ['x.FooResult', 'x.BarResult']),
group('x.FooResult', []),
group('x.BarResult', []),
]
const sel = projectSchema(ast, {}).types['x.ResultData'].selector
assert.ok(sel.ordered, 'a non-envelope `result` field must not request-correlate its union')
assert.equal(sel.correlated, undefined)
})
it('does not treat a record with an optional id/result as the response envelope', () => {
// The real envelope has a REQUIRED request id and result; an optional id means
// this is not the response envelope, so its union must keep its payload selector.
const optional = { n: 0, m: 1 }
const ast = [
group('x.CommandResponse', [field('id', ['uint'], optional), field('result', [ref('x.ResultData')])]),
union('x.ResultData', ['x.FooResult', 'x.BarResult']),
group('x.FooResult', []),
group('x.BarResult', []),
]
const sel = projectSchema(ast, {}).types['x.ResultData'].selector
assert.equal(sel.correlated, undefined)
assert.ok(sel.ordered)
})
it('resolves an alias variant to its leaf record when building structural requires', () => {
// x.Alias is a single-ref alias to x.A; its ordered `requires` must reflect
// x.A's required fields, not be left empty (which would always match).
const ast = [
union('x.U', ['x.Alias', 'x.B']),
{ Type: 'variable', Name: 'x.Alias', IsChoiceAddition: false, Comments: [], PropertyType: [ref('x.A')] },
group('x.A', [field('a', ['text'])]),
group('x.B', [field('b', ['text'])]),
]
assert.deepEqual(projectSchema(ast, {}).types['x.U'].selector, {
ordered: [
{ ref: 'x.Alias', requires: ['a'] },
{ ref: 'x.B', requires: ['b'] },
],
})
})
})
describe('checkCompleteness (input vs output, generator-independent)', () => {
it('fails when a command/event present in the AST is missing from the schema', () => {
const astWithExtra = [
...AST,
leaf('network.DroppedCmd', 'network.droppedCmd', 'network.SetCacheBehaviorParameters'),
]
const schema = projectSchema(AST, MODEL) // model does NOT know about droppedCmd
const errors = checkCompleteness(astWithExtra, schema)
assert.deepEqual(errors, ['dropped from schema: network.droppedCmd'])
})
it('does not fail for a known-incomplete (allowlisted) drop', () => {
const astWithKnown = [
...AST,
leaf('bluetooth.X', 'bluetooth.characteristicEventGenerated', 'network.SetCacheBehaviorParameters'),
]
assert.deepEqual(checkCompleteness(astWithKnown, projectSchema(AST, MODEL)), [])
})
it('flags an allowlisted method as stale once it is emitted', () => {
const schema = projectSchema(AST, MODEL)
schema.events.push({
domain: 'bluetooth',
method: 'bluetooth.characteristicEventGenerated',
name: 'characteristicEventGenerated',
params: null,
})
assert.deepEqual(checkCompleteness(AST, schema), [
'stale KNOWN_INCOMPLETE entry (now emitted, remove it): bluetooth.characteristicEventGenerated',
])
})
})
describe('checkSchema (referential integrity)', () => {
it('catches an unresolved ref nested inside a record field', () => {
const schema = {
schemaVersion: 1,
commands: [],
events: [],
types: {
'x.T': { kind: 'record', fields: [{ name: 'a', wire: 'a', required: true, type: { ref: 'x.Missing' } }] },
},
}
assert.deepEqual(checkSchema(schema), ['x.T.a: unresolved type x.Missing'])
})
it('catches an unresolved ref inside an alias', () => {
const schema = {
schemaVersion: 1,
commands: [],
events: [],
types: { 'x.A': { kind: 'alias', type: { ref: 'x.Missing' } } },
}
assert.deepEqual(checkSchema(schema), ['x.A: unresolved type x.Missing'])
})
it('catches an unresolved ref inside a record map', () => {
const schema = {
schemaVersion: 1,
commands: [],
events: [],
types: { 'x.T': { kind: 'record', fields: [], map: { ref: 'x.Missing' } } },
}
assert.deepEqual(checkSchema(schema), ['x.T.*: unresolved type x.Missing'])
})
it('flags a field that projected to an unknown primitive (unhandled CDDL type)', () => {
const schema = {
schemaVersion: 1,
commands: [],
events: [],
types: {
'x.T': { kind: 'record', fields: [{ name: 'a', wire: 'a', required: true, type: { primitive: 'unknown' } }] },
},
}
assert.deepEqual(checkSchema(schema), ['x.T.a: projected to an unknown primitive (unhandled CDDL type)'])
})
it('flags a synthetic type whose owner does not resolve', () => {
const schema = {
schemaVersion: 1,
commands: [],
events: [],
types: {
'x.E': { kind: 'enum', values: ['a', 'b'], synthetic: true, owner: 'x.Gone', label: 'E' },
},
}
assert.deepEqual(checkSchema(schema), ['x.E: synthetic owner x.Gone does not resolve'])
})
it('flags an empty inline record in a union arm (dropped type reference)', () => {
const schema = {
schemaVersion: 1,
commands: [],
events: [],
types: {
'x.U': { kind: 'alias', type: { union: [{ ref: 'x.A' }, { record: [] }] } },
'x.A': { kind: 'record', fields: [] }, // a legitimately-empty top-level record is NOT flagged
},
}
assert.deepEqual(checkSchema(schema), ['x.U: projected an empty inline record (dropped type reference)'])
})
it('rejects a structural selector whose arm shadows a later arm (subset requires)', () => {
const schema = {
schemaVersion: 1,
commands: [],
events: [],
types: {
'x.U': {
kind: 'union',
variants: ['x.A', 'x.B'],
selector: {
ordered: [
{ ref: 'x.A', requires: ['data'] },
{ ref: 'x.B', requires: ['data', 'more'] },
],
},
},
'x.A': { kind: 'record', fields: [] },
'x.B': { kind: 'record', fields: [] },
},
}
assert.deepEqual(checkSchema(schema), ['x.U: structural selector arm x.A shadows x.B (requires is a subset)'])
})
it('rejects a structural selector arm with no required fields to dispatch on', () => {
const schema = {
schemaVersion: 1,
commands: [],
events: [],
types: {
'x.U': {
kind: 'union',
variants: ['x.A', 'x.B'],
selector: {
ordered: [
{ ref: 'x.A', requires: [] },
{ ref: 'x.B', requires: ['b'] },
],
},
},
'x.A': { kind: 'record', fields: [] },
'x.B': { kind: 'record', fields: [] },
},
}
assert.deepEqual(checkSchema(schema), ['x.U: structural selector arm x.A has no required fields to dispatch on'])
})
it('accepts a correlated selector (resolved by request id, not the payload)', () => {
const schema = {
schemaVersion: 1,
commands: [],
events: [],
types: {
'x.ResultData': { kind: 'union', variants: ['x.A'], selector: { correlated: true } },
'x.A': { kind: 'record', fields: [] },
},
}
assert.deepEqual(checkSchema(schema), [])
})
it('exempts the envelope `result` position but flags any other correlated reference', () => {
const make = (fieldName, withId = true) => ({
schemaVersion: 1,
commands: [],
events: [],
types: {
Envelope: {
kind: 'record',
fields: [
...(withId ? [{ name: 'id', wire: 'id', required: true, type: { primitive: 'integer' } }] : []),
{ name: fieldName, wire: fieldName, required: true, type: { ref: 'x.ResultData' } },
],
},
'x.ResultData': { kind: 'union', variants: ['x.A'], selector: { correlated: true } },
'x.A': { kind: 'record', fields: [] },
},
})
assert.deepEqual(checkSchema(make('result')), []) // the envelope's correlation point — allowed
assert.deepEqual(checkSchema(make('payload')), [
'Envelope.payload: correlated union x.ResultData is reachable as a value (needs a payload selector)',
])
// A `result` field on a record that is NOT the envelope (no request id) gets no
// free pass — otherwise a too-broad envelope match could ship silently.
assert.deepEqual(checkSchema(make('result', false)), [
'Envelope.result: correlated union x.ResultData is reachable as a value (needs a payload selector)',
])
})
it('flags a correlated union used as a variant of a non-correlated union', () => {
const schema = {
schemaVersion: 1,
commands: [],
events: [],
types: {
'x.Value': {
kind: 'union',
variants: ['x.ResultData'],
selector: { ordered: [{ ref: 'x.ResultData', requires: ['k'] }] },
},
'x.ResultData': { kind: 'union', variants: ['x.A'], selector: { correlated: true } },
'x.A': { kind: 'record', fields: [{ name: 'k', wire: 'k', required: true, type: { primitive: 'string' } }] },
},
}
assert.deepEqual(checkSchema(schema), [
'x.Value: correlated union x.ResultData is reachable as a value (needs a payload selector)',
])
})
it('flags a command that emits null params while its envelope requires them', () => {
const schema = {
schemaVersion: 1,
commands: [{ domain: 'x', method: 'x.foo', name: 'foo', params: null, result: null }],
events: [],
types: {
'x.FooCommand': {
kind: 'record',
fields: [
{ name: 'method', wire: 'method', required: true, type: { const: 'x.foo' } },
{ name: 'params', wire: 'params', required: true, type: { ref: 'x.FooParams' } },
],
},
'x.FooParams': { kind: 'record', fields: [] },
},
}
assert.deepEqual(checkSchema(schema), ['x.foo: params null does not match required envelope params x.FooParams'])
})
})