registry: describe an Alexa interface as data
src/registry/ holds what the library knows about an interface: namespace, version, the page it was read from, properties with their value schemas and directives with their payload schemas. Five interfaces are described (Alexa, PowerController, BrightnessController, TemperatureSensor, EndpointHealth); the other 65 names of AlexaInterfaceType are stubs with the version and property names of 1.5.2. schema.ts is the run-time check behind it (241 lines, no new dependency), catalog.ts the vocabularies of the pages: 103 assets (23 units), 6 actions, 9 states, 56 display categories, 22 reserved words, 73 error types under 11 namespaces. AlexaInterface.getVersion() and getProps() read the registry; the two switch statements are gone (-167 lines). On the wire: Alexa.EndpointHealth is announced at 3.1 (was 3.3; the page is titled 3.1 and no page mentions 3.3), and TimeHoldController and Camera.LiveViewController at 3 and 1.7 (1.5.2 sent the string "UNKNOWN"). DisplayCategory gains VACUUM. New exports: registry, DeclarationError, SchemaError, Assets, Units, Actions, States, DisplayCategories and the descriptor types. Tests: 20 JSON examples of the five pages under test/fixtures/alexa-docs; every directive payload and property value in them parses with its descriptor. npm test: 57 pass (was 30) in 10.8 s, also on Node 18.20.8 and 20.20.2. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
128ca35c2a
commit
aa0ffd64ea
93 changed files with 4058 additions and 490 deletions
111
test/registry/schema.test.js
Normal file
111
test/registry/schema.test.js
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"use strict";
|
||||
// The schema helper of the registry: what parse() returns, and what the error says when the value does not fit.
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { SchemaError } = require("alex2node");
|
||||
// The helper is not part of the public surface; the descriptors are built with it.
|
||||
const { s } = require("../../dist/cjs/registry/schema.js");
|
||||
|
||||
// The message of the SchemaError that parse() throws.
|
||||
function refusal(schema, input, path) {
|
||||
try {
|
||||
schema.parse(input, path);
|
||||
} catch (err) {
|
||||
assert.ok(err instanceof SchemaError, `threw ${err}`);
|
||||
return err.message;
|
||||
}
|
||||
return assert.fail(`${JSON.stringify(input)} was accepted`);
|
||||
}
|
||||
|
||||
test("number: bounds, integers, and no NaN or text", () => {
|
||||
const percent = s.number({ min: 0, max: 100, integer: true });
|
||||
assert.equal(percent.parse(0), 0);
|
||||
assert.equal(percent.parse(100), 100);
|
||||
assert.equal(refusal(percent, 101, "payload.brightness"), "payload.brightness: expected an integer from 0 to 100, got 101");
|
||||
assert.equal(refusal(percent, 50.5, "payload.brightness"), "payload.brightness: expected an integer from 0 to 100, got 50.5");
|
||||
assert.equal(refusal(percent, "lots", "payload.brightness"), 'payload.brightness: expected an integer from 0 to 100, got "lots"');
|
||||
assert.equal(refusal(s.number(), NaN, "value"), "value: expected a number, got null");
|
||||
assert.equal(refusal(s.number({ gt: 0 }), 0, "range.precision"), "range.precision: expected a number greater than 0, got 0");
|
||||
assert.equal(s.number({ gt: 0 }).parse(0.5), 0.5);
|
||||
});
|
||||
|
||||
test("enum and literal: the value itself, the choices in the message", () => {
|
||||
const power = s.enum("ON", "OFF");
|
||||
assert.equal(power.parse("ON"), "ON");
|
||||
assert.deepEqual(power.values, ["ON", "OFF"]);
|
||||
assert.equal(refusal(power, "on", "powerState"), 'powerState: expected ON | OFF, got "on"');
|
||||
assert.equal(s.literal("FOUR_DIGIT_PIN").parse("FOUR_DIGIT_PIN"), "FOUR_DIGIT_PIN");
|
||||
assert.equal(refusal(s.literal(3), "3", "version"), 'version: expected 3, got "3"');
|
||||
});
|
||||
|
||||
test("object: the path of the key that is wrong; a key that is not in the shape is kept", () => {
|
||||
const payload = s.object({ rangeValueDelta: s.number(), rangeValueDeltaDefault: s.boolean() });
|
||||
assert.deepEqual(payload.parse({ rangeValueDelta: -5, rangeValueDeltaDefault: false }), { rangeValueDelta: -5, rangeValueDeltaDefault: false });
|
||||
assert.equal(
|
||||
refusal(payload, { rangeValueDelta: -5 }, "payload"),
|
||||
"payload.rangeValueDeltaDefault: expected true or false, got nothing"
|
||||
);
|
||||
assert.equal(refusal(payload, [1, 2], "payload"), "payload: expected an object with rangeValueDelta, rangeValueDeltaDefault, got [1,2]");
|
||||
assert.equal(refusal(payload, null, "payload"), "payload: expected an object with rangeValueDelta, rangeValueDeltaDefault, got null");
|
||||
// A field Alexa adds to a directive later reaches the handler
|
||||
assert.deepEqual(s.object({}).parse({ added: 1 }), { added: 1 });
|
||||
});
|
||||
|
||||
test("object with unknownKeys \"reject\": a misspelt option is named, with the ones that exist", () => {
|
||||
const options = s.object({ range: s.unknown(), presets: s.optional(s.unknown()) }, { unknownKeys: "reject" });
|
||||
assert.equal(refusal(options, { range: {}, preset: [] }), "preset: unknown key, the known ones are range, presets");
|
||||
assert.deepEqual(options.parse({ range: {} }), { range: {} });
|
||||
});
|
||||
|
||||
test("optional, nullable, array", () => {
|
||||
assert.equal(s.optional(s.number()).parse(undefined), undefined);
|
||||
assert.equal(refusal(s.optional(s.number()), null, "delta"), "delta: expected a number, got null");
|
||||
assert.equal(s.nullable(s.string()).parse(null), null);
|
||||
assert.equal(refusal(s.nullable(s.string()), 5, "mode"), "mode: expected a string or null, got 5");
|
||||
const modes = s.array(s.object({ value: s.string() }), { min: 2 });
|
||||
assert.deepEqual(modes.parse([{ value: "a" }, { value: "b" }]), [{ value: "a" }, { value: "b" }]);
|
||||
assert.equal(refusal(modes, [{ value: "a" }], "supportedModes"), 'supportedModes: expected a list with 2 or more entries, got [{"value":"a"}]');
|
||||
assert.equal(refusal(modes, [{ value: "a" }, { value: 2 }], "supportedModes"), "supportedModes[1].value: expected a string, got 2");
|
||||
});
|
||||
|
||||
test("temperature: the three scales of alexa-property-schemas.html, the scale named when it is wrong", () => {
|
||||
const temperature = s.temperature();
|
||||
assert.deepEqual(temperature.parse({ value: 68.0, scale: "FAHRENHEIT" }), { value: 68, scale: "FAHRENHEIT" });
|
||||
assert.deepEqual(temperature.parse({ value: 293.15, scale: "KELVIN" }), { value: 293.15, scale: "KELVIN" });
|
||||
assert.equal(
|
||||
refusal(temperature, { value: 20, scale: "Celsius" }, "payload.targetSetpoint"),
|
||||
'payload.targetSetpoint.scale: expected CELSIUS | FAHRENHEIT | KELVIN, got "Celsius"'
|
||||
);
|
||||
assert.equal(refusal(temperature, 20, "temperature"), "temperature: expected an object with value, scale, got 20");
|
||||
});
|
||||
|
||||
test("dateTime and duration: the examples of alexa-property-schemas.html", () => {
|
||||
for (const time of ["2017-08-30T01:18:21Z", "2017-08-30T01:18:21.123Z", "2017-10-04T14:00Z"]) assert.equal(s.dateTime().parse(time), time);
|
||||
for (const time of ["2017-08-30T01:18:21+02:00", "2017-08-30 01:18:21Z", "2017-13-40T01:18:21Z", 1504055901]) {
|
||||
assert.match(refusal(s.dateTime(), time, "start"), /^start: expected a UTC time like 2017-08-30T01:18:21Z, got /);
|
||||
}
|
||||
for (const length of ["PT3M15S", "PT-30S", "PT30M", "PT1H"]) assert.equal(s.duration().parse(length), length);
|
||||
for (const length of ["PT", "3M", "P1D", "PT3M15"]) {
|
||||
assert.match(refusal(s.duration(), length, "duration"), /^duration: expected a duration like PT3M15S, got /);
|
||||
}
|
||||
});
|
||||
|
||||
test("timeInterval: one or two of start, end and duration", () => {
|
||||
const interval = s.timeInterval();
|
||||
for (const example of [
|
||||
{ start: "2017-10-04T14:00Z", end: "2017-10-04T14:15Z" },
|
||||
{ start: "2017-10-04T14:00Z", duration: "PT30M" },
|
||||
{ duration: "PT30M" },
|
||||
]) assert.deepEqual(interval.parse(example), example);
|
||||
const all = { start: "2017-10-04T14:00Z", end: "2017-10-04T14:15Z", duration: "PT15M" };
|
||||
assert.match(refusal(interval, all, "holdUntil"), /^holdUntil: expected a time interval with one or two of start, end, duration, got /);
|
||||
assert.match(refusal(interval, {}, "holdUntil"), /^holdUntil: expected a time interval with one or two of start, end, duration, got \{\}$/);
|
||||
assert.equal(refusal(interval, { end: "tomorrow" }, "holdUntil"), 'holdUntil.end: expected a UTC time like 2017-08-30T01:18:21Z, got "tomorrow"');
|
||||
});
|
||||
|
||||
test("an error without a path is the problem alone, and a long value is cut short", () => {
|
||||
assert.equal(refusal(s.boolean(), "yes"), 'expected true or false, got "yes"');
|
||||
const message = refusal(s.number(), "x".repeat(200), "value");
|
||||
assert.equal(message.length, "value: expected a number, got ".length + 60);
|
||||
assert.ok(message.endsWith("..."));
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue