The thermostat is described from its page: one, two or three setpoints with holdUntil, AdjustTargetTemperature, SetThermostatMode with the six modes (EM_HEAT is new), ResumeSchedule, adaptiveRecoveryStatus, and its seven error types under Alexa.ThermostatController. A declaration gives supportedModes, supportsScheduling and the properties the device has; without them discovery is what 1.5.2 sent: HEAT, COOL, AUTO, OFF and the three setpoints with thermostatMode. supportedModes without HEAT or COOL is refused where it is declared. ThermostatController.Schedule replaces its stub. Wire change for a 1.x declaration of it: version 3.2 (was 1) and scheduleEnabled listed (was nothing). SetAdaptiveRecovery is taken only with supportsAdaptiveRecovery. 24 examples of three pages are saved as fixtures; the page folds the SetWeeklySchedule example away, its payload is tested from the tables. 207 tests pass, 191 before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
119 lines
6.8 KiB
JavaScript
119 lines
6.8 KiB
JavaScript
"use strict";
|
|
// The interfaces of test/fixtures/interfaces.js, given the directives of their pages: the handler gets the payload as
|
|
// the page prints it, and a payload that does not fit is answered with INVALID_VALUE without the handler.
|
|
const { test } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const {
|
|
Alex2MQTT, AlexaErrors, LockController, MemoryPublisher, PowerLevelController, ThermostatController,
|
|
ThermostatControllerSchedule, registry,
|
|
} = require("alex2node");
|
|
const interfaces = require("../fixtures/interfaces.js");
|
|
const { doc, examples } = require("../helpers/fixtures.js");
|
|
const { directive } = require("../helpers/harness.js");
|
|
|
|
function bridgeWithout() {
|
|
const sent = new MemoryPublisher();
|
|
const logged = [];
|
|
const bridge = new Alex2MQTT("u", "p", "root", false, { publisher: sent, log: (line) => logged.push(line) });
|
|
const device = bridge.addDevice({ endpointId: "device-1", name: "Device", categories: ["OTHER"] });
|
|
let count = 0;
|
|
// The examples of a page share one message id and one correlation token
|
|
const receive = ({ header, payload }) => {
|
|
count += 1;
|
|
const unique = { ...header, messageId: `message-${count}`, correlationToken: `ct-${count}` };
|
|
const message = { header: unique, endpoint: { endpointId: "device-1" }, payload };
|
|
return bridge.receive("root/device-1/alexaDirective", JSON.stringify(message));
|
|
};
|
|
/** What was published, as [the last part of the topic, the namespace and the name of the event, its payload]. */
|
|
const answers = () => sent.published.map(({ topic, message: { event } }) => [topic.split("/").pop(), event.header.namespace, event.header.name, event.payload]);
|
|
return { device, sent, logged, receive, answers };
|
|
}
|
|
|
|
for (const { namespace, page, declared, refused } of interfaces.filter((entry) => entry.refused)) {
|
|
test(`${namespace}: the directives of ${page}.html reach the handler, a payload that does not fit is answered with INVALID_VALUE`, async () => {
|
|
const { device, receive, answers } = bridgeWithout();
|
|
const seen = [];
|
|
device.add(registry.get(namespace), declared[0].options).on("*", (ctx) => {
|
|
seen.push([ctx.name, ctx.payload]);
|
|
return ctx.respond();
|
|
});
|
|
|
|
const printed = examples(page, ".directive").map((name) => doc(page, name).directive);
|
|
for (const example of printed) await receive(example);
|
|
assert.deepEqual(seen, printed.map(({ header, payload }) => [header.name, payload]));
|
|
assert.deepEqual(answers(), printed.map(() => ["alexaResponce", "Alexa", "Response", {}]));
|
|
|
|
for (const [name, payload] of refused) await receive(directive(namespace, name, "device-1", "ct-1", payload));
|
|
assert.deepEqual(
|
|
answers().slice(printed.length),
|
|
refused.map(([, , message]) => ["alexaResponce", "Alexa", "ErrorResponse", { type: "INVALID_VALUE", message }])
|
|
);
|
|
assert.equal(seen.length, printed.length, "the handler is not called with a payload that does not fit");
|
|
});
|
|
}
|
|
|
|
test("Alexa.LockController: a slow lock defers without a warning, and answers as the page does", async () => {
|
|
const { device, sent, logged, receive, answers } = bridgeWithout();
|
|
const lock = device.add(LockController);
|
|
lock.on("Lock", async (ctx) => {
|
|
await ctx.defer(15);
|
|
return ctx.respond((s) => s.set(lock, "lockState", "LOCKED"));
|
|
});
|
|
const level = device.add(PowerLevelController);
|
|
level.on("SetPowerLevel", async (ctx) => {
|
|
await ctx.defer();
|
|
return ctx.respond((s) => s.set(level, "powerLevel", ctx.payload.powerLevel));
|
|
});
|
|
|
|
await receive(doc("alexa-lockcontroller", "Lock.directive").directive);
|
|
const deferred = doc("alexa-lockcontroller", "Lock-slow.deferred");
|
|
const answer = doc("alexa-lockcontroller", "Lock-slow.response");
|
|
assert.deepEqual(answers(), [
|
|
["alexaResponce", "Alexa", "DeferredResponse", deferred.event.payload],
|
|
["deferredResponse", "Alexa", "Response", answer.event.payload],
|
|
]);
|
|
assert.equal("context" in sent.published[0].message, false);
|
|
const [{ namespace, name, value }] = answer.context.properties;
|
|
const reported = sent.published[1].message.context.properties.find((property) => property.namespace === namespace);
|
|
assert.deepEqual([reported.name, reported.value], [name, value]);
|
|
|
|
// The page of the PowerLevelController has no DeferredResponse
|
|
await receive(doc("alexa-powerlevelcontroller", "SetPowerLevel.directive").directive);
|
|
assert.deepEqual(logged.filter((line) => line.includes("DeferredResponse")), [
|
|
"warning: Amazon documents no DeferredResponse for Alexa.PowerLevelController, Alexa may not wait for the answer",
|
|
]);
|
|
});
|
|
|
|
test("Alexa.ThermostatController: an error of the thermostat goes under its namespace, a temperature out of range under Alexa", async () => {
|
|
const { device, receive, answers } = bridgeWithout();
|
|
const celsius = (value) => ({ value, scale: "CELSIUS" });
|
|
const thrown = [
|
|
AlexaErrors.setpointsTooClose("The setpoints are too close.", celsius(2)),
|
|
AlexaErrors.of("THERMOSTAT_IS_OFF", "The thermostat is currently off."),
|
|
AlexaErrors.temperatureOutOfRange("The requested temperature of -15 is out of range.", { minimumValue: celsius(15), maximumValue: celsius(30) }),
|
|
];
|
|
let count = 0;
|
|
device.add(ThermostatController).on("SetTargetTemperature", () => { throw thrown[count++]; });
|
|
device.add(ThermostatControllerSchedule, { supportedFanModes: ["AUTO"], supportsAdaptiveRecovery: false })
|
|
.on("SetWeeklySchedule", (ctx) => ctx.error("INSUFFICIENT_SPACE", "the thermostat keeps four entries a day"));
|
|
|
|
const set = doc("alexa-thermostatcontroller", "SetTargetTemperature-single.directive").directive;
|
|
for (let i = 0; i < thrown.length; i += 1) await receive(set);
|
|
const printed = ["REQUESTED_SETPOINTS_TOO_CLOSE", "THERMOSTAT_IS_OFF", "TEMPERATURE_VALUE_OUT_OF_RANGE"]
|
|
.map((type) => doc("alexa-thermostatcontroller-errorresponse", `${type}.error`).event);
|
|
assert.deepEqual(answers(), printed.map(({ header, payload }) => ["alexaResponce", header.namespace, "ErrorResponse", payload]));
|
|
assert.deepEqual(printed.map(({ header }) => header.namespace), ["Alexa.ThermostatController", "Alexa.ThermostatController", "Alexa"]);
|
|
|
|
await receive(directive("Alexa.ThermostatController.Schedule", "SetWeeklySchedule", "device-1", "ct", {}));
|
|
assert.deepEqual(answers().at(-1), [
|
|
"alexaResponce", "Alexa.ThermostatController.Schedule", "ErrorResponse",
|
|
{ type: "INSUFFICIENT_SPACE", message: "the thermostat keeps four entries a day" },
|
|
]);
|
|
|
|
// Declared without adaptive recovery, the thermostat is not sent the directive that switches it
|
|
await receive(doc("alexa-thermostatcontroller-schedule", "SetAdaptiveRecovery.directive").directive);
|
|
assert.deepEqual(answers().at(-1), ["alexaResponce", "Alexa", "ErrorResponse", {
|
|
type: "INVALID_DIRECTIVE",
|
|
message: "Alexa.ThermostatController.Schedule as device-1 declares it does not take SetAdaptiveRecovery",
|
|
}]);
|
|
});
|