inventoresed
Version:
Z-Wave driver written entirely in JavaScript/TypeScript
480 lines (459 loc) • 11.8 kB
text/typescript
import { createModel } from "@xstate/test";
import type { Message } from "@zwave-js/serial";
import {
createDeferredPromise,
DeferredPromise,
} from "alcalzone-shared/deferred-promise";
import { assign, interpret, Machine, State } from "xstate";
import {
dummyCallbackNOK,
dummyCallbackOK,
dummyCallbackPartial,
dummyMessageNoResponseNoCallback,
dummyMessageNoResponseWithCallback,
dummyMessageUnrelated,
dummyMessageWithResponseNoCallback,
dummyMessageWithResponseWithCallback,
dummyResponseNOK,
dummyResponseOK,
} from "../test/messages";
import {
createSerialAPICommandMachine,
SerialAPICommandDoneData,
SerialAPICommandInterpreter,
SerialAPICommandMachineParams,
} from "./SerialAPICommandMachine";
/* eslint-disable @typescript-eslint/ban-types */
interface TestMachineStateSchema {
states: {
init: {};
sending: {};
waitForACK: {};
waitForResponse: {};
waitForCallback: {};
unsolicited: {};
success: {};
failure: {};
};
}
/* eslint-enable @typescript-eslint/ban-types */
interface TestMachineContext {
resp: boolean;
cb: boolean;
gotPartialCB?: boolean;
attempt: number;
}
type TestMachineEvents =
| {
type: "CREATE";
expectsResponse: boolean;
expectsCallback: boolean;
}
| { type: "SEND_SUCCESS" }
| { type: "SEND_FAILURE" }
| { type: "ACK" }
| { type: "ACK_TIMEOUT" }
| { type: "CAN" }
| { type: "NAK" }
| { type: "RESPONSE_OK" }
| { type: "RESPONSE_NOK" }
| { type: "RESPONSE_TIMEOUT" }
| { type: "CALLBACK_OK" }
| { type: "CALLBACK_NOK" }
| { type: "CALLBACK_PARTIAL" }
| { type: "CALLBACK_TIMEOUT" }
| { type: "UNSOLICITED" };
const messages = {
false: {
false: dummyMessageNoResponseNoCallback,
true: dummyMessageNoResponseWithCallback,
},
true: {
false: dummyMessageWithResponseNoCallback,
true: dummyMessageWithResponseWithCallback,
},
};
interface MockImplementations {
sendData: jest.Mock<any, any>;
notifyRetry: jest.Mock<any, any>;
}
interface TestContext {
interpreter: SerialAPICommandInterpreter;
implementations: MockImplementations;
sendDataPromise?: DeferredPromise<any> | undefined;
machineResult?: any;
expectedFailureReason?: (SerialAPICommandDoneData & {
type: "failure";
})["reason"];
expectedResult?: Message;
respondedUnsolicited?: boolean;
}
jest.useFakeTimers();
const machineParams: SerialAPICommandMachineParams = {
timeouts: {
ack: 1000,
response: 1600,
sendDataCallback: 65000,
},
attempts: {
controller: 3,
},
};
describe("lib/driver/SerialAPICommandMachine", () => {
const testMachine = Machine<
TestMachineContext,
TestMachineStateSchema,
TestMachineEvents
>(
{
id: "SerialAPICommandTest",
initial: "init",
context: {
attempt: 0,
resp: false,
cb: false,
},
states: {
init: {
on: {
CREATE: {
target: "sending",
actions: assign<any, any>((_, evt) => ({
resp: evt.resp,
cb: evt.cb,
})),
},
},
},
sending: {
entry: assign<TestMachineContext, any>({
attempt: (ctx) => ctx.attempt + 1,
}),
on: {
SEND_SUCCESS: "waitForACK",
SEND_FAILURE: [
{ target: "sending", cond: "maySendAgain" },
{ target: "failure" },
],
UNSOLICITED: "unsolicited",
},
meta: {
test: async (
{
interpreter,
implementations: { sendData },
}: TestContext,
state: State<TestMachineContext>,
) => {
// Skip the wait time if there should be any
const att = state.context.attempt;
if (att > 1) {
expect(interpreter.state.value).toBe(
"retryWait",
);
jest.advanceTimersByTime(
100 + (att - 1) * 1000,
);
}
// Assert that sendData was called
expect(interpreter.state.value).toBe("sending");
// but not more than 3 times
expect(att).toBeLessThanOrEqual(3);
expect(sendData.mock.calls.length).toBe(att);
},
},
},
waitForACK: {
on: {
ACK: [
{
target: "waitForResponse",
cond: "expectsResponse",
},
{
target: "waitForCallback",
cond: "expectsCallback",
},
{ target: "success" },
],
NAK: [
{ target: "sending", cond: "maySendAgain" },
{ target: "failure" },
],
CAN: [
{ target: "sending", cond: "maySendAgain" },
{ target: "failure" },
],
ACK_TIMEOUT: [
{ target: "sending", cond: "maySendAgain" },
{ target: "failure" },
],
UNSOLICITED: "unsolicited",
},
},
waitForResponse: {
on: {
RESPONSE_OK: [
{
target: "waitForCallback",
cond: "expectsCallback",
},
{ target: "success" },
],
RESPONSE_NOK: [
{ target: "sending", cond: "maySendAgain" },
{ target: "failure" },
],
RESPONSE_TIMEOUT: [
{ target: "sending", cond: "maySendAgain" },
{ target: "failure" },
],
UNSOLICITED: "unsolicited",
},
},
waitForCallback: {
on: {
CALLBACK_PARTIAL: {
target: "waitForCallback",
actions: assign<TestMachineContext, any>({
gotPartialCB: true,
}),
},
CALLBACK_OK: "success",
CALLBACK_NOK: "failure",
CALLBACK_TIMEOUT: "failure",
UNSOLICITED: "unsolicited",
},
},
unsolicited: {
meta: {
test: ({ respondedUnsolicited }: TestContext) => {
expect(respondedUnsolicited).toBeTrue();
},
},
},
success: {
meta: {
test: ({
interpreter,
expectedResult,
machineResult,
}: TestContext) => {
// Ensure that the interpreter is in "success" state
expect(interpreter.state.value).toBe("success");
// with the correct reason
expect(machineResult).toBeObject();
expect(machineResult).toMatchObject({
type: "success",
result: expectedResult,
});
},
},
},
failure: {
meta: {
test: ({
interpreter,
expectedFailureReason,
machineResult,
implementations: { sendData },
}: TestContext) => {
// Ensure that the interpreter is in "failure" state
expect(interpreter.state.value).toBe("failure");
// with the correct reason
expect(machineResult).toBeObject();
expect(machineResult).toMatchObject({
type: "failure",
reason: expectedFailureReason,
});
// and that at most three attempts were made
expect(
sendData.mock.calls.length,
).toBeLessThanOrEqual(3);
},
},
},
},
},
{
guards: {
maySendAgain: (ctx) => ctx.attempt < 3,
expectsResponse: (ctx) => ctx.resp,
expectsCallback: (ctx) => ctx.cb,
},
},
);
const testModel = createModel<TestContext, TestMachineContext>(
testMachine,
).withEvents({
CREATE: {
// test all possible combinations of message expectations
cases: [
{ resp: false, cb: false },
{ resp: false, cb: true },
{ resp: true, cb: false },
{ resp: true, cb: true },
],
},
SEND_SUCCESS: {
exec: ({ sendDataPromise }) => {
sendDataPromise?.resolve(undefined as any);
},
},
SEND_FAILURE: {
exec: ({ sendDataPromise }) => {
sendDataPromise?.reject();
},
},
ACK: ({ interpreter }) => {
interpreter.send("ACK");
},
NAK: ({ interpreter }) => {
interpreter.send("NAK");
},
CAN: ({ interpreter }) => {
interpreter.send("CAN");
},
RESPONSE_OK: ({ interpreter }) => {
interpreter.send({ type: "message", message: dummyResponseOK });
},
RESPONSE_NOK: ({ interpreter }) => {
interpreter.send({ type: "message", message: dummyResponseNOK });
},
CALLBACK_OK: ({ interpreter }) => {
interpreter.send({ type: "message", message: dummyCallbackOK });
},
CALLBACK_NOK: ({ interpreter }) => {
interpreter.send({ type: "message", message: dummyCallbackNOK });
},
CALLBACK_PARTIAL: ({ interpreter }) => {
interpreter.send({
type: "message",
message: dummyCallbackPartial,
});
},
UNSOLICITED: ({ interpreter }) => {
interpreter.send({
type: "message",
message: dummyMessageUnrelated,
});
},
ACK_TIMEOUT: () => {
jest.advanceTimersByTime(machineParams.timeouts.ack);
},
RESPONSE_TIMEOUT: () => {
jest.advanceTimersByTime(machineParams.timeouts.response);
},
CALLBACK_TIMEOUT: () => {
jest.advanceTimersByTime(machineParams.timeouts.sendDataCallback);
},
});
const testPlans = testModel.getSimplePathPlans();
testPlans.forEach((plan) => {
if (plan.state.value === "idle") return;
const planDescription = plan.description.replace(
` (${JSON.stringify(plan.state.context)})`,
"",
);
describe(planDescription, () => {
plan.paths.forEach((path) => {
it(path.description, () => {
// eslint-disable-next-line prefer-const
let context: TestContext;
const sendData = jest.fn().mockImplementation(() => {
context.sendDataPromise = createDeferredPromise();
return context.sendDataPromise;
});
const notifyRetry = jest.fn();
const timestamp = () => 0;
const log = () => {};
const logOutgoingMessage = () => {};
const implementations = {
sendData,
notifyRetry,
timestamp,
log,
logOutgoingMessage,
};
// parse message from test description
// CREATE ({"expectsResponse":false,"expectsCallback":false})
const createMachineRegex = /CREATE \((?<json>[^\)]+)\)/;
const match = createMachineRegex.exec(path.description);
if (!match?.groups?.json)
return path.test(undefined as any);
// And create a test machine with it
const msg = JSON.parse(match.groups.json);
const machine = createSerialAPICommandMachine(
// @ts-ignore
messages[msg.resp][msg.cb],
implementations as any,
machineParams,
);
context = {
interpreter: interpret(machine),
implementations,
};
context.interpreter
.onEvent((evt) => {
if (evt.type === "unsolicited") {
context.respondedUnsolicited = true;
}
})
.onDone((evt) => {
context.machineResult = evt.data;
});
// context.interpreter.onTransition((state) => {
// if (state.changed) console.log(state.value);
// });
context.interpreter.start();
if (plan.state.value === "failure") {
// parse expected failure reason from plan
const failureEvent =
path.segments.slice(-1)?.[0]?.event.type;
context.expectedFailureReason = (() => {
switch (failureEvent) {
case "SEND_FAILURE":
return "send failure";
case "RESPONSE_NOK":
return "response NOK";
case "CALLBACK_NOK":
return "callback NOK";
case "ACK_TIMEOUT":
return "ACK timeout";
case "RESPONSE_TIMEOUT":
return "response timeout";
case "CALLBACK_TIMEOUT":
return "callback timeout";
case "NAK":
case "CAN":
return failureEvent;
}
})();
} else if (plan.state.value === "success") {
// parse expected success event from plan
const successEvent =
path.segments.slice(-1)?.[0]?.event.type;
context.expectedResult = (() => {
switch (successEvent) {
case "ACK":
return;
case "RESPONSE_OK":
return dummyResponseOK;
case "CALLBACK_OK":
return dummyCallbackOK;
}
})();
}
return path.test(context);
});
});
});
});
it("coverage", () => {
testModel.testCoverage({
filter: (stateNode) => {
return !!stateNode.meta;
},
});
});
});