"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/); });