Alex2Node/test/device/raise.test.js
David 2cbcde563e device: changeReport(cause, fill) sends a ChangeReport on the 2.0 API
The design has it (section 5) and 2.0 lacked it: a ChangeReport could only be
sent with getChangeReport() of 1.x, which has no helper for a contact or a
motion sensor. fill sets what changed, and after unchanged() what did not;
the rest of the context is the state of device.state(). The report goes to
<root>/changeReport. It throws MessageError without a changed property and
resolves with the failure when the device is on no bridge, as raise() does.
Four tests in test/device/raise.test.js.

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

210 lines
11 KiB
JavaScript

"use strict";
// device.raise(): an event nobody asked for goes to <root>/event, and one that would not arrive is refused.
// device.changeReport(): a change of state nobody asked for goes to <root>/changeReport.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const {
Alex2MQTT, AlexaInterfaceType, ContactSensor, DoorbellEventSource, MemoryPublisher, MessageError, PowerController, SceneController,
SimpleEventSource, asset, topics,
} = require("alex2node");
const { endpoint } = require("../helpers/endpoint.js");
const { doc } = require("../helpers/fixtures.js");
const { UUID_V4 } = require("../helpers/harness.js");
function doorbell() {
const sent = new MemoryPublisher();
const errors = [];
const bridge = new Alex2MQTT("u", "p", "root", false, { publisher: sent, log: () => {} });
bridge.on("error", (err) => errors.push(err));
const device = bridge.addDevice({ endpointId: "door-1", name: "Front door", categories: ["DOORBELL"] });
device.add(DoorbellEventSource);
return { bridge, device, sent, errors };
}
const button = (name, events) => ({
instance: `Remote.${name}`,
friendlyNames: [asset(`Alexa.Button.${name}`)],
supportedEvents: events.map((push) => ({ id: `Button.${push}.1`, friendlyNames: [asset(`Alexa.Button.${push}`)] })),
});
const refused = (raise, problem) => assert.throws(raise, (err) => err instanceof MessageError && err.problem === problem);
test("raise() publishes the event on <root>/event, with the endpoint, a new messageId, the time and the usual cause", async () => {
const { device, sent } = doorbell();
const example = doc("alexa-doorbelleventsource", "DoorbellPress.event");
const printed = example.event;
const before = Date.now();
assert.deepEqual(await device.raise(DoorbellEventSource, "DoorbellPress"), { ok: true, topic: "root/event" });
await device.raise(DoorbellEventSource, "DoorbellPress", printed.payload);
await device.raise(DoorbellEventSource, "DoorbellPress", { cause: { type: "APP_INTERACTION" } }, { messageId: "press-3" });
assert.equal(topics.event("root"), "root/event");
assert.deepEqual(sent.published.map(({ topic }) => topic), ["root/event", "root/event", "root/event"]);
for (const { message } of sent.published) {
assert.deepEqual(Object.keys(message).sort(), Object.keys(example).sort());
assert.deepEqual(message.context, example.context);
}
const [first, second, third] = sent.published.map(({ message }) => message.event);
for (const event of [first, second, third]) {
assert.deepEqual(event.endpoint, { endpointId: "door-1" });
const { messageId, ...header } = event.header;
assert.deepEqual(header, { namespace: printed.header.namespace, name: printed.header.name, payloadVersion: printed.header.payloadVersion });
}
assert.match(first.header.messageId, UUID_V4);
assert.match(second.header.messageId, UUID_V4);
assert.notEqual(first.header.messageId, second.header.messageId);
assert.equal(third.header.messageId, "press-3");
assert.deepEqual(first.payload.cause, { type: "PHYSICAL_INTERACTION" });
assert.ok(Date.parse(first.payload.timestamp) >= before && Date.parse(first.payload.timestamp) <= Date.now(), first.payload.timestamp);
assert.deepEqual(second.payload, printed.payload);
assert.deepEqual(third.payload.cause, { type: "APP_INTERACTION" });
});
test("raise() for an instance: the Event of a button has the instance in its header and the version of the interface", async () => {
const sent = new MemoryPublisher();
const remote = endpoint("remote-1", "Remote", ["REMOTE"]);
remote.publisher = sent;
const top = remote.add(SimpleEventSource, button("TopButton", ["SinglePush", "DoublePush"]));
remote.add(SimpleEventSource, button("BottomButton", ["SinglePush"]));
const example = doc("alexa-simpleeventsource", "Event.event");
const printed = example.event;
await remote.raise(SimpleEventSource, "Event", printed.payload, { instance: top.instance, messageId: "push-1" });
assert.deepEqual(sent.published, [{
topic: "root/event",
message: {
event: {
header: { namespace: "Alexa.SimpleEventSource", name: "Event", instance: "Remote.TopButton", messageId: "push-1", payloadVersion: "1.0" },
endpoint: { endpointId: "remote-1" },
payload: printed.payload,
},
},
}]);
assert.equal(printed.header.payloadVersion, "1.0");
assert.deepEqual(Object.keys(sent.published[0].message), Object.keys(example));
const raised = "remote-1: Alexa.SimpleEventSource.Event was not raised: ";
assert.throws(() => remote.raise(SimpleEventSource, "Event", { id: "Button.SinglePush.1" }), {
name: "MessageError",
message: `${raised}the device did not declare Alexa.SimpleEventSource. Declare it with add() first, and pass the instance that raises the event`,
});
assert.throws(() => remote.raise(SimpleEventSource, "Event", { id: "Button.SinglePush.1" }, { instance: "Remote.LeftButton" }), {
message: `${raised}the device did not declare the instance "Remote.LeftButton" of Alexa.SimpleEventSource. Declare it with add() first`,
});
assert.throws(() => remote.raise(SimpleEventSource, "Event", {}, { instance: top.instance }), {
message: `${raised}payload.id: expected a string of 1 or more characters, got nothing`,
});
assert.equal(sent.published.length, 1);
});
test("raise() refuses an event the device has no capability for, and one that would not arrive", () => {
const { device, sent } = doorbell();
device.add(PowerController);
const scene = endpoint("scene-1", "Evening", ["SCENE_TRIGGER"]);
scene.setDescription("Evening scene");
scene.publisher = sent;
scene.add(SceneController);
const was = (name) => `${name} was not raised: `;
refused(() => scene.raise(DoorbellEventSource, "DoorbellPress"), `${was("Alexa.DoorbellEventSource.DoorbellPress")}the device did not declare Alexa.DoorbellEventSource. Declare it with add() first`);
refused(() => device.raise(DoorbellEventSource, "Knock"), `${was("Alexa.DoorbellEventSource.Knock")}it is not an event of the interface, which has DoorbellPress`);
refused(() => device.raise(PowerController, "TurnedOn"), `${was("Alexa.PowerController.TurnedOn")}it is not an event of the interface, which has none`);
refused(
() => scene.raise(SceneController, "ActivationStarted"),
`${was("Alexa.SceneController.ActivationStarted")}it answers a directive. Send it with respond() in the handler of the directive`
);
refused(
() => device.raise(DoorbellEventSource, "DoorbellPress", { timestamp: "just now" }),
`${was("Alexa.DoorbellEventSource.DoorbellPress")}payload.timestamp: expected a UTC time like 2017-08-30T01:18:21Z, got "just now"`
);
refused(
() => device.raise(DoorbellEventSource, "DoorbellPress", { cause: { type: "KNOCK" } }),
`${was("Alexa.DoorbellEventSource.DoorbellPress")}payload.cause.type: expected APP_INTERACTION | PERIODIC_POLL | PHYSICAL_INTERACTION | RULE_TRIGGER | VOICE_INTERACTION, got "KNOCK"`
);
refused(
() => device.raise(DoorbellEventSource, "DoorbellPress", { picture: "x".repeat(topics.EVENT_BYTES) }),
`${was("Alexa.DoorbellEventSource.DoorbellPress")}it is 16309 bytes as JSON, Alex2MQTT takes 16000`
);
assert.deepEqual(sent.published, []);
});
test("raise() of a doorbell declared the 1.x way, of a device on no bridge and with a broker that is gone", async () => {
const sent = new MemoryPublisher();
const bell = endpoint("door-2", "Back door", ["DOORBELL"]);
bell.addCapability(AlexaInterfaceType.DOORBELL_EVENT_SOURCE);
const reported = [];
bell.onPublishError = (err) => reported.push(err.message);
const nowhere = await bell.raise(DoorbellEventSource, "DoorbellPress");
assert.equal(nowhere.ok, false);
assert.deepEqual(reported, ["nothing was published to root/event: the device is on no bridge, register it with addDevice() or registerDevice()"]);
bell.publisher = sent;
assert.equal((await bell.raise(DoorbellEventSource, "DoorbellPress")).ok, true);
assert.equal(sent.published[0].message.event.endpoint.endpointId, "door-2");
const { device, sent: broker, errors } = doorbell();
broker.failWith = new Error("the broker is gone");
const result = await device.raise(DoorbellEventSource, "DoorbellPress");
assert.deepEqual([result.ok, result.topic, result.error.message], [false, "root/event", "the broker is gone"]);
assert.deepEqual(errors.map((err) => err.message), ["the broker is gone"]);
});
// device.changeReport(): the 2.0 way to send a ChangeReport
function sensor() {
const sent = new MemoryPublisher();
const bridge = new Alex2MQTT("u", "p", "root", false, { publisher: sent, log: () => {} });
const device = bridge.addDevice({ endpointId: "door-2", name: "Back door", categories: ["CONTACT_SENSOR"] });
const contact = device.add(ContactSensor, { proactivelyReported: true });
const state = { contact: "NOT_DETECTED" };
device.state((s) => s.set(contact, "detectionState", state.contact).health("OK"));
return { device, contact, sent, state };
}
test("changeReport() publishes what changed on <root>/changeReport, with the rest of the state as the context", async () => {
const { device, contact, sent } = sensor();
const result = await device.changeReport("PHYSICAL_INTERACTION", (s) => s.set(contact, "detectionState", "DETECTED"));
assert.deepEqual(result, { ok: true, topic: "root/changeReport" });
const [{ topic, message }] = sent.published;
assert.equal(topic, "root/changeReport");
assert.equal(message.event.header.name, "ChangeReport");
assert.equal("correlationToken" in message.event.header, false);
assert.equal(message.event.endpoint.endpointId, "door-2");
assert.equal(message.event.payload.change.cause.type, "PHYSICAL_INTERACTION");
assert.deepEqual(message.event.payload.change.properties.map(({ namespace, name, value }) => [namespace, name, value]),
[["Alexa.ContactSensor", "detectionState", "DETECTED"]]);
// The property that changed is not in the context a second time, with the value device.state() still has
assert.deepEqual(message.context.properties.map(({ namespace, name, value }) => [namespace, name, value]),
[["Alexa.EndpointHealth", "connectivity", { value: "OK" }]]);
});
test("changeReport() takes what did not change after unchanged(), in place of the same property of device.state()", async () => {
const { device, contact, sent } = sensor();
await device.changeReport("PERIODIC_POLL", (s) => s.set(contact, "detectionState", "DETECTED").unchanged().health("UNREACHABLE"));
assert.deepEqual(sent.published[0].message.context.properties.map(({ name, value }) => [name, value]),
[["connectivity", { value: "UNREACHABLE" }]]);
});
test("changeReport() without a property that changed throws and publishes nothing", () => {
const { device, sent } = sensor();
assert.throws(() => device.changeReport("PHYSICAL_INTERACTION", (s) => s.unchanged().health("OK")),
(err) => err instanceof MessageError && /at least one property that changed/.test(err.message));
assert.equal(sent.published.length, 0);
});
test("changeReport() of a device on no bridge resolves with the failure", async () => {
const { device, contact } = sensor();
device.publisher = null;
const result = await device.changeReport("PHYSICAL_INTERACTION", (s) => s.set(contact, "detectionState", "DETECTED"));
assert.equal(result.ok, false);
assert.match(result.error.message, /nothing was published to root\/changeReport/);
});