inventoresed
Version:
Z-Wave driver written entirely in JavaScript/TypeScript
446 lines (420 loc) • 11.3 kB
text/typescript
import { createModel } from "@xstate/test";
import { BasicCCGet, BasicCCReport, BasicCCSet } from "@zwave-js/cc/BasicCC";
import { MessagePriority, SecurityManager } from "@zwave-js/core";
import type { Message } from "@zwave-js/serial";
import { createDeferredPromise } from "alcalzone-shared/deferred-promise";
import { assign, interpret, Machine, State } from "xstate";
import {
SendDataAbort,
SendDataRequest,
} from "../serialapi/transport/SendDataMessages";
import { dummyCallbackNOK, dummyResponseNOK } from "../test/messages";
import { createEmptyMockDriver } from "../test/mocks";
import {
CommandQueueInterpreter,
createCommandQueueMachine,
} from "./CommandQueueMachine";
import type { Driver } from "./Driver";
import type {
SerialAPICommandDoneData,
SerialAPICommandError,
} from "./SerialAPICommandMachine";
import { createWrapperMachine } from "./StateMachineShared";
import { Transaction } from "./Transaction";
/* eslint-disable @typescript-eslint/ban-types */
interface TestMachineStateSchema {
states: {
idle: {};
sending: {};
aborting: {};
maybeDone: {};
done: {};
};
}
/* eslint-enable @typescript-eslint/ban-types */
interface TestMachineContext {
transactions?: Transaction[];
index: number;
expectedCalls: number;
definitelyDone: boolean;
}
type TestMachineEvents =
| {
type: "ADD";
transaction: Transaction;
}
// | { type: "ABORT" }
| {
type: "API_FAILED" | "ABORT_FAILED";
reason: (SerialAPICommandDoneData & {
type: "failure";
})["reason"];
}
| { type: "API_SUCCESS" | "ABORT_SUCCESS"; result: Message };
interface TestContext {
interpreter: CommandQueueInterpreter;
expectedTransactions: Transaction[];
expectedResults: Message[];
actualResults: Message[];
expectedReasons: SerialAPICommandError[];
actualReasons: SerialAPICommandError[];
}
jest.mock("./SerialAPICommandMachine");
const mockSerialAPIMachine = jest.requireMock("./SerialAPICommandMachine")
.createSerialAPICommandMachine as jest.Mock;
describe("lib/driver/CommandQueueMachine", () => {
const fakeDriver = createEmptyMockDriver() as unknown as Driver;
const sm = new SecurityManager({
ownNodeId: 1,
nonceTimeout: 500,
networkKey: Buffer.alloc(16, 1),
});
(fakeDriver as any).securityManager = sm;
const sendDataBasicGet = new SendDataRequest(fakeDriver, {
command: new BasicCCGet(fakeDriver, {
nodeId: 2,
}),
});
const sendDataBasicReport = new SendDataRequest(fakeDriver, {
command: new BasicCCReport(fakeDriver, {
nodeId: 2,
currentValue: 50,
}),
});
const sendDataBasicSet = new SendDataRequest(fakeDriver, {
command: new BasicCCSet(fakeDriver, {
nodeId: 2,
targetValue: 22,
}),
});
function createTransaction(msg: Message) {
const ret = new Transaction(fakeDriver, {
message: msg,
promise: createDeferredPromise(),
priority: MessagePriority.Normal,
parts: {
current: msg,
} as any,
});
(ret as any).toJSON = () => ({
message: msg.constructor.name,
});
return ret;
}
const testTransactions = {
BasicSet: createTransaction(sendDataBasicSet),
BasicGet: createTransaction(sendDataBasicGet),
BasicReport: createTransaction(sendDataBasicReport),
"response NOK": createTransaction(dummyResponseNOK),
"callback NOK": createTransaction(dummyCallbackNOK),
};
beforeEach(() => {
mockSerialAPIMachine.mockReset();
});
const testMachine = Machine<
TestMachineContext,
TestMachineStateSchema,
TestMachineEvents
>(
{
id: "CommandQueueTest",
initial: "idle",
context: {
index: -1,
expectedCalls: 0,
definitelyDone: false,
},
states: {
idle: {
on: {
ADD: {
target: "sending",
actions: assign<TestMachineContext, any>({
transactions: (_, evt) =>
evt.commands.map(
// @ts-expect-error
(cmd) => testTransactions[cmd],
),
}),
},
},
},
sending: {
entry: assign<TestMachineContext, any>({
index: (ctx) => ctx.index + 1,
expectedCalls: (ctx) => ctx.expectedCalls + 1,
}),
on: {
API_SUCCESS: "maybeDone",
API_FAILED: [
{ cond: "isCbTimeout", target: "aborting" },
{ target: "maybeDone" },
],
// ABORT: {
// actions: assign<TestMachineContext, any>({
// definitelyDone: true,
// }),
// target: "aborting",
// },
},
meta: {
test: async (
{ expectedTransactions }: TestContext,
state: State<TestMachineContext>,
) => {
expect(mockSerialAPIMachine).toBeCalledTimes(
state.context.expectedCalls,
);
expect(
mockSerialAPIMachine.mock.calls[
state.context.expectedCalls - 1
][0],
).toBe(
expectedTransactions[state.context.index]
.message,
);
},
},
},
aborting: {
entry: assign<TestMachineContext, any>({
expectedCalls: (ctx) => ctx.expectedCalls + 1,
}),
on: {
ABORT_SUCCESS: "maybeDone",
ABORT_FAILED: "maybeDone",
},
meta: {
test: async (
_: TestContext,
state: State<TestMachineContext>,
) => {
expect(mockSerialAPIMachine).toBeCalledTimes(
state.context.expectedCalls,
);
expect(
mockSerialAPIMachine.mock.calls[
state.context.expectedCalls - 1
][0],
).toBeInstanceOf(SendDataAbort);
},
},
},
maybeDone: {
always: [
{ cond: "definitelyDone", target: "done" },
{ cond: "queueEmpty", target: "done" },
{ target: "sending" },
],
},
done: {
meta: {
test: async ({
interpreter,
actualResults,
expectedResults,
actualReasons,
expectedReasons,
}: TestContext) => {
expect(
// @ts-expect-error 🤷🏻♂️
interpreter.children.get("child")!.state.value,
).toBe("idle");
expect(actualResults).toContainAllValues(
expectedResults,
);
expect(actualReasons).toContainAllValues(
expectedReasons,
);
},
},
},
},
},
{
guards: {
definitelyDone: (ctx) => ctx.definitelyDone,
queueEmpty: (ctx) => {
// index is increased in the next step, so we need to add +1 here
return ctx.index + 1 >= (ctx.transactions?.length ?? 0);
},
isCbTimeout: (_, evt: any) => evt.reason === "callback timeout",
},
},
);
const testModel = createModel<TestContext, TestMachineContext>(
testMachine,
).withEvents({
ADD: {
exec: ({ interpreter }, event) => {
const cmds = (event as any).commands as string[];
for (const cmd of cmds) {
interpreter.send({
type: "add",
// @ts-expect-error
transaction: testTransactions[cmd],
from: "T1",
});
}
},
cases: [
{ commands: ["BasicGet"] },
{ commands: ["BasicSet", "BasicGet"] },
],
},
ABORT: {
exec: ({ interpreter }) => {
interpreter.send({
// @ts-expect-error
type: "remove",
transaction: interpreter.state.context.currentTransaction,
});
},
},
API_SUCCESS: {
exec: ({ interpreter }, event) => {
interpreter.send({
type: "done.invoke.execute",
data: {
type: "success",
// @ts-expect-error
result: testTransactions[event.command].message,
},
} as any);
},
cases: [{ command: "BasicReport" }],
},
API_FAILED: {
exec: ({ interpreter }, event) => {
interpreter.send({
type: "done.invoke.execute",
data: {
type: "failure",
// @ts-expect-error
reason: event.reason,
// @ts-expect-error
message: testTransactions[event.reason]?.message,
},
} as any);
},
cases: [
{ reason: "send failure" },
{ reason: "CAN" },
{ reason: "NAK" },
{ reason: "ACK timeout" },
{ reason: "response timeout" },
{ reason: "callback timeout" },
{ reason: "response NOK" },
{ reason: "callback NOK" },
],
},
ABORT_SUCCESS: {
exec: ({ interpreter }) => {
interpreter.send({
type: "done.invoke.executeSendDataAbort",
data: {
type: "success",
},
} as any);
},
},
ABORT_FAILED: {
exec: ({ interpreter }, event) => {
interpreter.send({
type: "done.invoke.executeSendDataAbort",
data: {
type: "failure",
// @ts-expect-error
reason: event.reason,
// @ts-expect-error
message: testTransactions[event.reason]?.message,
},
} as any);
},
cases: [
{ reason: "send failure" },
{ reason: "CAN" },
{ reason: "NAK" },
{ reason: "ACK timeout" },
],
},
});
const testPlans = testModel.getSimplePathPlans();
testPlans.forEach((plan) => {
if (plan.state.value === "idle") return;
// if (plan.state.value !== "done") return;
const planDescription = plan.description.replace(
` (${JSON.stringify(plan.state.context)})`,
"",
);
describe(planDescription, () => {
plan.paths.forEach((path) => {
// Use this to limit testing to a single invocation path
// if (
// !path.description.endsWith(
// `via ADD ({"commands":["BasicSet","BasicGet"]}) → API_FAILED ({"reason":"callback timeout"}) → ABORT_FAILED ({"reason":"callback timeout"}) → RESET → ABORT_FAILED ({"reason":"callback NOK"})`,
// )
// ) {
// return;
// }
it(path.description, () => {
const machine = createCommandQueueMachine(
{
createSendDataAbort: () =>
new SendDataAbort(fakeDriver),
} as any,
{} as any,
);
const expectedResults = path.segments
.filter((s) => s.event.type === "API_SUCCESS")
.map((s) => s.event)
// @ts-expect-error
.map((evt) => testTransactions[evt.command].message);
const expectedReasons = path.segments
.filter((s) => s.event.type === "API_FAILED")
.map((s) => (s.event as any).reason);
const expectedTransactions = path.segments
.filter((s) => s.event.type === "ADD")
.map((s) => (s.event as any).commands)
.reduce((acc, cur) => [...acc, ...cur], [])
// @ts-expect-error
.map((cmd) => testTransactions[cmd]);
const wrapper = createWrapperMachine(machine);
const context: TestContext = {
interpreter: interpret(wrapper),
actualResults: [],
expectedResults,
actualReasons: [],
expectedReasons,
expectedTransactions,
};
context.interpreter.onEvent((evt) => {
// Events that are sent to the transaction machine need their payload inspected
if (evt.type === "forward") evt = (evt as any).payload;
if (evt.type === "command_success") {
context.actualResults.push((evt as any).result);
} else if (evt.type === "command_failure") {
context.actualReasons.push((evt as any).reason);
}
});
// context.interpreter.onTransition((state, evt) => {
// console.log(
// `in state ${state.value} b/c of ${JSON.stringify(
// evt,
// )}`,
// );
// });
context.interpreter.start();
return path.test(context);
});
});
});
});
it("coverage", () => {
testModel.testCoverage({
filter: (stateNode) => {
return !!stateNode.meta;
},
});
});
});