Alex2Node/test/messages/errors.test.js
David 94b13f733a errors: typed helpers for every documented error type
AlexaErrors has a helper for each of the 73 types of the table on
alexa-errorresponse.html, named after the type in camel case: 63 take the
message only, 10 take the payload fields their page documents. A field the
page requires (reason, currentDeviceMode, currentChargeState, resourceType)
is a required argument, so leaving it out does not compile; from JavaScript
the helper throws a TypeError that lists the values. validRange is optional,
as the page says, and has both ends when given.
temperatureOutOfRange and setpointsTooClose stay as shorter names.
The helpers are compared with the 15 examples of the four error pages.

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

176 lines
9.8 KiB
JavaScript

"use strict";
// AlexaError, the helpers of AlexaErrors and the namespace an ErrorResponse goes under.
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { AlexaError, AlexaErrors, AlexaErrorType, messages } = require("alex2node");
const { ERROR_TYPES } = require("../../dist/cjs/registry/catalog.js");
const { doc, examples } = require("../helpers/fixtures");
const celsius = (value) => ({ value, scale: "CELSIUS" });
const camelCase = (type) => type.toLowerCase().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
test("the namespace follows the type: the interface that documents it, Alexa for the rest", () => {
const namespaces = Object.fromEntries(
["ENDPOINT_UNREACHABLE", "THERMOSTAT_IS_OFF", "UNAUTHORIZED", "OBSTACLE_DETECTED", "SAFETY_BEAM_BREACHED", "NOT_A_TYPE", "constructor"]
.map((type) => [type, messages.errorNamespace(type)])
);
assert.deepEqual(namespaces, {
ENDPOINT_UNREACHABLE: "Alexa",
THERMOSTAT_IS_OFF: "Alexa.ThermostatController",
UNAUTHORIZED: "Alexa.SecurityPanelController",
OBSTACLE_DETECTED: "Alexa.Safety",
SAFETY_BEAM_BREACHED: "Alexa.Safety",
NOT_A_TYPE: "Alexa",
constructor: "Alexa",
});
});
test("every type of the 1.x enum has a namespace that starts with Alexa", () => {
for (const type of Object.values(AlexaErrorType)) assert.match(messages.errorNamespace(type), /^Alexa(\.[A-Za-z]+)*$/, type);
});
test("AlexaError: an Error with the type, the message for Alexa, the fields of the payload and the namespace", () => {
const error = AlexaErrors.of("THERMOSTAT_IS_OFF", "the thermostat is off");
assert.ok(error instanceof AlexaError && error instanceof Error);
assert.equal(error.name, "AlexaError");
assert.equal(error.message, "THERMOSTAT_IS_OFF: the thermostat is off");
assert.deepEqual(
{ type: error.type, alexaMessage: error.alexaMessage, extra: error.extra, namespace: error.namespace },
{ type: "THERMOSTAT_IS_OFF", alexaMessage: "the thermostat is off", extra: {}, namespace: "Alexa.ThermostatController" }
);
assert.equal(new AlexaError("THERMOSTAT_IS_OFF", "off", {}, "Alexa").namespace, "Alexa");
});
test("the helpers: the type, the namespace and the payload fields as the pages spell them", () => {
const cases = [
[AlexaErrors.valueOutOfRange("m", { minimumValue: 1, maximumValue: 10 }),
"Alexa", "VALUE_OUT_OF_RANGE", { validRange: { minimumValue: 1, maximumValue: 10 } }],
[AlexaErrors.temperatureOutOfRange("m", { minimumValue: celsius(15), maximumValue: celsius(30) }),
"Alexa", "TEMPERATURE_VALUE_OUT_OF_RANGE", { validRange: { minimumValue: celsius(15), maximumValue: celsius(30) } }],
[AlexaErrors.notSupportedInCurrentMode("m", "COLOR"), "Alexa", "NOT_SUPPORTED_IN_CURRENT_MODE", { currentDeviceMode: "COLOR" }],
[AlexaErrors.endpointLowPower("m", 5), "Alexa", "ENDPOINT_LOW_POWER", { percentageState: 5 }],
[AlexaErrors.endpointLowPower("m"), "Alexa", "ENDPOINT_LOW_POWER", {}],
[AlexaErrors.endpointControlUnavailable("m", "DEEP_SLEEP_MODE"), "Alexa", "ENDPOINT_CONTROL_UNAVAILABLE", { reason: "DEEP_SLEEP_MODE" }],
[AlexaErrors.notSupportedWithCurrentBatteryChargeState("m", "NOT_CONNECTED_TO_POWER", 75),
"Alexa", "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE",
{ currentChargeState: "NOT_CONNECTED_TO_POWER", currentChargeLevelInPercentage: 75 }],
[AlexaErrors.notSupportedWithCurrentBatteryChargeState("m", "FULLY_CHARGED"),
"Alexa", "NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", { currentChargeState: "FULLY_CHARGED" }],
[AlexaErrors.insufficientResource("m", "WATER"), "Alexa", "INSUFFICIENT_RESOURCE", { resourceType: "WATER" }],
[AlexaErrors.maintenanceRequired("m", "EMPTY_BIN"), "Alexa", "MAINTENANCE_REQUIRED", { maintenanceAction: "EMPTY_BIN" }],
[AlexaErrors.maintenanceRequired("m"), "Alexa", "MAINTENANCE_REQUIRED", {}],
[AlexaErrors.setpointsTooClose("m", celsius(2)),
"Alexa.ThermostatController", "REQUESTED_SETPOINTS_TOO_CLOSE", { minimumTemperatureDelta: celsius(2) }],
[AlexaErrors.bypassNeeded("m", [{ friendlyName: "Front door" }]),
"Alexa.SecurityPanelController", "BYPASS_NEEDED", { endpointsNeedingBypass: [{ friendlyName: "Front door" }] }],
[AlexaErrors.of("OBSTACLE_DETECTED", "m"), "Alexa.Safety", "OBSTACLE_DETECTED", {}],
[AlexaErrors.valueOutOfRange("m"), "Alexa", "VALUE_OUT_OF_RANGE", {}],
[AlexaErrors.temperatureValueOutOfRange("m"), "Alexa", "TEMPERATURE_VALUE_OUT_OF_RANGE", {}],
[AlexaErrors.requestedSetpointsTooClose("m"), "Alexa.ThermostatController", "REQUESTED_SETPOINTS_TOO_CLOSE", {}],
[AlexaErrors.bypassNeeded("m"), "Alexa.SecurityPanelController", "BYPASS_NEEDED", {}],
];
for (const [error, namespace, type, extra] of cases) {
assert.deepEqual(
{ namespace: error.namespace, type: error.type, alexaMessage: error.alexaMessage, extra: error.extra },
{ namespace, type, alexaMessage: "m", extra },
type
);
}
});
test("errorResponse: the namespace of the type, or the one the caller names", () => {
const fields = { endpointId: "thermostat", correlationToken: "ct", messageId: "m", type: "THERMOSTAT_IS_OFF", message: "off" };
assert.deepEqual(messages.errorResponse(fields).event.header, {
namespace: "Alexa.ThermostatController", name: "ErrorResponse", messageId: "m", correlationToken: "ct", payloadVersion: "3",
});
assert.equal(messages.errorResponse({ ...fields, namespace: "Alexa" }).event.header.namespace, "Alexa");
});
test("every type of the table has a helper under its name in camel case, which takes the message", () => {
const types = Object.keys(ERROR_TYPES);
assert.equal(types.length, 73);
// The fields these types cannot do without
const fields = {
NOT_SUPPORTED_IN_CURRENT_MODE: ["ECO"],
ENDPOINT_CONTROL_UNAVAILABLE: ["UNKNOWN"],
NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE: ["CURRENTLY_CHARGING"],
INSUFFICIENT_RESOURCE: ["WATER"],
};
for (const type of types) {
const helper = AlexaErrors[camelCase(type)];
assert.equal(typeof helper, "function", `AlexaErrors.${camelCase(type)}`);
const error = helper("what went wrong", ...(fields[type] || []));
assert.ok(error instanceof AlexaError, type);
assert.deepEqual(
{ type: error.type, alexaMessage: error.alexaMessage, namespace: error.namespace },
{ type, alexaMessage: "what went wrong", namespace: ERROR_TYPES[type] }
);
if (!fields[type]) assert.deepEqual(error.extra, {}, type);
}
// of() and the two shorter names are the only helpers that are not named after a type
assert.deepEqual(
Object.keys(AlexaErrors).filter((name) => !types.some((type) => camelCase(type) === name)).sort(),
["of", "setpointsTooClose", "temperatureOutOfRange"]
);
});
test("the helpers build the examples of the error pages", () => {
const open = [
{ friendlyName: "side window sensor", endpointId: "Endpoint ID" },
{ friendlyName: "front door sensor", endpointId: "Endpoint ID" },
{ friendlyName: "water sensor" },
];
// What the helper of each example is called with after the message
const fields = {
ENDPOINT_CONTROL_UNAVAILABLE: ["NO_CONNECTIVITY_PACKAGE_ENABLED"],
ENDPOINT_LOW_POWER: [5],
NOT_SUPPORTED_IN_CURRENT_MODE: ["DEMAND_RESPONSE"],
NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE: ["ALREADY_CHARGED_TO_REQUIRED_LEVEL", 75],
TEMPERATURE_VALUE_OUT_OF_RANGE: [{ minimumValue: celsius(15), maximumValue: celsius(30) }],
VALUE_OUT_OF_RANGE: [{ minimumValue: 0, maximumValue: 100 }],
REQUESTED_SETPOINTS_TOO_CLOSE: [celsius(2)],
BYPASS_NEEDED: [open],
};
const pages = [
"alexa-errorresponse", "alexa-safety-errorresponse", "alexa-securitypanelcontroller-errorresponse",
"alexa-thermostatcontroller-errorresponse",
];
let compared = 0;
for (const page of pages) {
for (const example of examples(page, ".error")) {
const { header, endpoint, payload } = doc(page, example).event;
// The page prints the type of NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE with a space after it
const type = payload.type.trim();
const error = AlexaErrors[camelCase(type)](payload.message, ...(fields[type] || []));
const { event } = messages.errorResponse({
endpointId: endpoint.endpointId,
messageId: header.messageId,
correlationToken: header.correlationToken,
type: error.type,
message: error.alexaMessage,
extra: error.extra,
namespace: error.namespace,
});
assert.deepEqual(event.header, header, `${page} ${example}`);
assert.deepEqual(event.payload, { ...payload, type }, `${page} ${example}`);
compared += 1;
}
}
assert.equal(compared, 15);
});
test("a helper called without a field it needs, or with a value the page does not have, says what to pass", () => {
assert.throws(
() => AlexaErrors.endpointControlUnavailable("no connection"),
{ name: "TypeError", message: /^AlexaErrors\.endpointControlUnavailable\(\) got undefined as reason: pass one of DEEP_SLEEP_MODE, / }
);
assert.throws(() => AlexaErrors.notSupportedInCurrentMode("m", "PARTY"), /got "PARTY" as currentDeviceMode: pass one of ASLEEP, COLOR, /);
assert.throws(() => AlexaErrors.notSupportedWithCurrentBatteryChargeState("m"), /got undefined as currentChargeState/);
assert.throws(() => AlexaErrors.insufficientResource("m", "SOAP"), /got "SOAP" as resourceType: pass one of WATER/);
assert.throws(() => AlexaErrors.maintenanceRequired("m", "CLEAN"), /got "CLEAN" as maintenanceAction: pass one of EMPTY_BIN/);
assert.throws(
() => AlexaErrors.valueOutOfRange("m", { minimumValue: 0 }),
/^TypeError: AlexaErrors\.valueOutOfRange\(\) got a validRange without maximumValue: pass both ends, or no range$/
);
assert.throws(() => AlexaErrors.temperatureOutOfRange("m", { maximumValue: celsius(30) }), /temperatureValueOutOfRange\(\) got a validRange without minimumValue/);
});