Alex2Node/test/registry/semantics.test.js
David 2558e21787 registry: RangeController, ModeController, ToggleController with resources, presets and semantics
The three generic controllers are described in full and leave the stub table (62 stubs remain). A declaration
takes an instance name, friendly names from text() and asset(), and the options of the interface: range, unit and
presets for a range; supportedModes and ordered for a mode; semantics() for all three, with actions mapped to
directives and states mapped to a value or a range of values.

Refused at device.add(), each with the endpoint, interface and instance in the message: no instance name or no
friendly name; an instance declared twice; a first friendly name or a phrase used by another capability of the
endpoint; a reserved word, an asset id that is not in the catalog; range.max not above range.min; a preset off the
precision grid or outside the range; fewer than two modes or a mode listed twice; a mapping to a directive the
interface does not have, to a payload that does not fit, to a mode that is not listed, to AdjustMode on modes
that are not ordered; SetEcoOn, SetEcoOff, EcoOn and EcoOff on anything but a toggle.

For 1.x callers: Alexa.RangeController lists rangeValue (1.5.2 sent supported: []); addSupportedModes() announces
a mode given as a string as { value } and notes it, and takes { ordered: false } as a second argument, the default
stays true as 1.5.2 sent it (device.add() defaults to false). Declared the 1.x way, the zoo fan and curtain give
the same JSON as before.

Tests: the zoo blind, whose range, unit and semantics were written by hand into the JSON of 1.5.2, is declared
with device.add() and equals that JSON plus the Alexa capability; five capability objects of Amazon's examples
are reproduced the same way. 27 examples of four pages added. npm test: 108 pass (was 85) in 10.5-11.3 s, also on
Node 18.20.8 and 20.20.2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-28 15:50:25 +00:00

104 lines
6.8 KiB
JavaScript

"use strict";
// semantics(): the mappings of phrases to directives and of states to values, as discovery lists them.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { Actions, AlexaActions, ModeController, RangeController, SchemaError, States, ToggleController, semantics } = require("alex2node");
const { doc } = require("../helpers/fixtures.js");
// The schema of the semantics option of an interface, and the message of the SchemaError it throws.
const semanticsOf = (descriptor) => ({
parse: (input) => descriptor.options.parse({ ...COMPLETE[descriptor.namespace], semantics: input }).semantics,
});
const COMPLETE = {
"Alexa.RangeController": { range: { min: 0, max: 100, precision: 1 } },
"Alexa.ModeController": {
supportedModes: ["Up", "Down"].map((name) => ({ value: `Position.${name}`, friendlyNames: [{ "@type": "text", value: { text: name, locale: "en-US" } }] })),
},
"Alexa.ToggleController": {},
};
function refusal(descriptor, input) {
try {
semanticsOf(descriptor).parse(input);
} catch (err) {
assert.ok(err instanceof SchemaError, `threw ${err}`);
return err.message;
}
return assert.fail("the semantics were accepted");
}
test("semantics() builds the Semantics object of alexa-discovery-objects.html", () => {
const built = semantics()
.action(["Close", "Lower"], "SetRangeValue", { rangeValue: 0 })
.action(["Open", "Raise"], "SetRangeValue", { rangeValue: 100 })
.state("Closed", 0)
.stateRange("Open", 1, 100);
const { semantics: example } = doc("alexa-discovery-objects", "semantics");
assert.deepEqual(semanticsOf(RangeController).parse(built), example);
assert.deepEqual(built.actionMappings[0], doc("alexa-discovery-objects", "actionMapping"));
assert.deepEqual(built.stateMappings[1], doc("alexa-discovery-objects", "stateMapping"));
// What the schema returns is the plain object, and it takes that object as it takes the builder
assert.equal(Object.getPrototypeOf(semanticsOf(RangeController).parse(built)), Object.prototype);
assert.deepEqual(semanticsOf(RangeController).parse(example), example);
});
test("a phrase and a state are given by name, by id or by the AlexaActions enum of 1.x", () => {
const { actionMappings, stateMappings } = semantics()
.action("Open", "TurnOn")
.action("Alexa.Actions.Close", "TurnOff")
.action([AlexaActions.Raise, Actions[3]], "TurnOn")
.state(["Closed", States[0]], "OFF");
assert.deepEqual(actionMappings.map((mapping) => mapping.actions), [
["Alexa.Actions.Open"], ["Alexa.Actions.Close"], ["Alexa.Actions.Raise", "Alexa.Actions.Lower"],
]);
assert.deepEqual(stateMappings[0].states, ["Alexa.States.Closed", "Alexa.States.Open"]);
// No payload given, none in discovery; an empty one is kept, as in the ToggleController examples
assert.deepEqual(actionMappings[0].directive, { name: "TurnOn" });
assert.deepEqual(semantics().action("Open", "TurnOn", {}).actionMappings[0].directive, { name: "TurnOn", payload: {} });
assert.deepEqual(semanticsOf(ToggleController).parse(semantics().action("Open", "TurnOn")).actionMappings[0].directive, { name: "TurnOn" });
});
test("a mapping is checked against the directives and the property of the interface", () => {
const cases = [
[RangeController, semantics().action("Open", "SetRange", { rangeValue: 100 }),
'semantics.actionMappings[0].directive.name: expected a directive of the interface: SetRangeValue, AdjustRangeValue, got "SetRange"'],
[RangeController, semantics().action("Open", "SetRangeValue", { rangeValue: "100" }),
'semantics.actionMappings[0].directive.payload.rangeValue: expected a number, got "100"'],
[RangeController, semantics().action("Open", "SetRangeValue"),
"semantics.actionMappings[0].directive.payload.rangeValue: expected a number, got nothing"],
[RangeController, semantics().action("Lower", "AdjustRangeValue", { rangeValueDelta: -10 }),
"semantics.actionMappings[0].directive.payload.rangeValueDeltaDefault: expected true or false, got nothing"],
[RangeController, semantics().action("Shut", "SetRangeValue", { rangeValue: 0 }),
'semantics.actionMappings[0].actions[0]: expected a phrase of Alexa.Actions: Open, Close, Raise, Lower, got "Alexa.Actions.Shut"'],
[RangeController, semantics().state("Ajar", 50),
'semantics.stateMappings[0].states[0]: expected a state of Alexa.States: Open, Closed, Low, Empty, Full, Done, Stuck, got "Alexa.States.Ajar"'],
[RangeController, semantics().state("Closed", "OFF"), 'semantics.stateMappings[0].value: expected a number, got "OFF"'],
[RangeController, semantics().stateRange("Open", 100, 1), "semantics.stateMappings[0].range: the minimumValue 100 is above the maximumValue 1"],
[ToggleController, semantics().state("Open", "on"), 'semantics.stateMappings[0].value: expected ON | OFF, got "on"'],
[ToggleController, semantics().stateRange("Open", 1, 100), 'semantics.stateMappings[0].@type: expected StatesToValue, got "StatesToRange"'],
[ModeController, semantics().state("Closed", 0), "semantics.stateMappings[0].value: expected a string of 1 or more characters, got 0"],
[ToggleController, semantics(), "semantics: expected semantics() with at least one action or state, got {}"],
[ToggleController, "open", 'semantics: expected semantics() with at least one action or state, got "open"'],
[ToggleController, { actionMappings: [{ "@type": "ActionToDirective", actions: ["Alexa.Actions.Open"], directive: { name: "TurnOn" } }] },
'semantics.actionMappings[0].@type: expected "ActionsToDirective", got "ActionToDirective"'],
];
for (const [descriptor, input, message] of cases) assert.equal(refusal(descriptor, input), message);
});
test("the phrases and states of Alexa hunches are for a ToggleController", () => {
const eco = semantics().action("SetEcoOn", "TurnOn").action("SetEcoOff", "TurnOff").state("EcoOn", "ON").state("EcoOff", "OFF");
assert.deepEqual(semanticsOf(ToggleController).parse(eco), {
actionMappings: [
{ "@type": "ActionsToDirective", actions: ["Alexa.Actions.SetEcoOn"], directive: { name: "TurnOn" } },
{ "@type": "ActionsToDirective", actions: ["Alexa.Actions.SetEcoOff"], directive: { name: "TurnOff" } },
],
stateMappings: [
{ "@type": "StatesToValue", states: ["Alexa.States.EcoOn"], value: "ON" },
{ "@type": "StatesToValue", states: ["Alexa.States.EcoOff"], value: "OFF" },
],
});
assert.match(
refusal(RangeController, semantics().action("SetEcoOn", "SetRangeValue", { rangeValue: 20 })),
/^semantics\.actionMappings\[0\]\.actions\[0\]: expected a phrase of Alexa\.Actions: Open, Close, Raise, Lower, got "Alexa\.Actions\.SetEcoOn"$/
);
assert.match(refusal(ModeController, semantics().state("EcoOn", "Position.Up")), /expected a state of Alexa\.States: Open, Closed, Low, Empty, Full, Done, Stuck, got/);
});