inventoresed
Version:
Z-Wave driver written entirely in JavaScript/TypeScript
452 lines (418 loc) • 11.6 kB
text/typescript
import { createModel } from "@xstate/test";
import { BasicCCGet, BasicCCReport, BasicCCSet } from "@zwave-js/cc";
import {
assertZWaveError,
MessagePriority,
TransmitStatus,
} from "@zwave-js/core";
import type { Message } from "@zwave-js/serial";
import { assign, StateValue } from "xstate";
import { interpret, Interpreter } from "xstate/lib/interpreter";
import { createMachine } from "xstate/lib/Machine";
import { ApplicationCommandRequest } from "../serialapi/application/ApplicationCommandRequest";
import {
GetControllerIdRequest,
GetControllerIdResponse,
} from "../serialapi/memory/GetControllerIdMessages";
import {
SendDataRequest,
SendDataRequestTransmitReport,
} from "../serialapi/transport/SendDataMessages";
import { createEmptyMockDriver } from "../test/mocks";
import type { Driver } from "./Driver";
import { createMessageGenerator } from "./MessageGenerators";
import { createWrapperMachine } from "./StateMachineShared";
import { Transaction } from "./Transaction";
import {
createTransactionMachine,
TransactionMachineInterpreter,
} from "./TransactionMachine";
interface TestMachineContext {
retryCount: number;
maxRetries: number;
}
type TestMachineEvents =
| {
type: "CREATE";
target: string;
}
| { type: "RETRY_TIMEOUT" }
// The TICK event is necessary to run a tick of the event loop including the message generator
| { type: "TICK" }
// The simple execution path
| { type: "SUCCESS_SIMPLE" }
| { type: "FAILURE_SIMPLE" }
// The SendData execution path
| { type: "SUCCESS_SENDDATA" }
| { type: "FAILURE_SENDDATA" };
interface MockImplementations {
notifyRetry: jest.Mock;
rejectTransaction: jest.Mock;
}
interface TestContext {
fakeDriver: Driver;
interpreter: TransactionMachineInterpreter;
testMessages: Record<string, Message>;
actualResult?: Message;
expectedResult?: Message;
actualError?: Error;
expectedError?: string;
forwardedMessages: Message[];
transactionDone: boolean;
// implementations: MockImplementations;
}
jest.useFakeTimers();
function assertWrappedMachineState<T extends Interpreter<any, any, any, any>>(
interpreter: T,
state: StateValue,
): void {
// @ts-expect-error for some reason TS doesn't like to access state
expect(interpreter.children.get("child")!.state.value).toEqual(state);
}
describe("lib/driver/TransactionMachine", () => {
const testMachine = createMachine<TestMachineContext, TestMachineEvents>(
{
id: "TransactionMachineTest",
initial: "init",
context: {
retryCount: 0,
maxRetries: 0,
},
states: {
init: {
on: {
CREATE: [
{
cond: (_, evt) =>
evt.target === "execute_simple",
target: "execute_simple",
},
{
cond: (_, evt) =>
evt.target === "execute_senddata",
target: "execute_senddata",
actions: assign({
maxRetries: (_) => 2,
}),
},
],
},
},
// Simple execution path
execute_simple: { on: { TICK: "respond_simple" } },
respond_simple: {
on: {
SUCCESS_SIMPLE: "finalize_simple",
FAILURE_SIMPLE: "finalize_simple",
},
meta: {
test: ({
interpreter,
forwardedMessages,
testMessages,
}: TestContext) => {
assertWrappedMachineState(interpreter, "execute");
expect(forwardedMessages).toContain(
testMessages.GetControllerIdRequest,
);
},
},
},
finalize_simple: { on: { TICK: "done" } },
// SendData execution path
execute_senddata: { on: { TICK: "respond_senddata" } },
respond_senddata: {
on: {
SUCCESS_SENDDATA: "finalize_senddata",
FAILURE_SENDDATA: [
{ cond: "mayRetry", target: "retry_senddata" },
{ target: "finalize_senddata" },
],
},
meta: {
test: ({
interpreter,
forwardedMessages,
testMessages,
}: TestContext) => {
assertWrappedMachineState(interpreter, "execute");
expect(forwardedMessages).toContain(
testMessages.BasicSet,
);
},
},
},
retry_senddata: {
entry: assign({
retryCount: (ctx) => ctx.retryCount + 1,
}),
on: {
RETRY_TIMEOUT: "execute_senddata",
},
meta: {
test: ({ interpreter }: TestContext) => {
assertWrappedMachineState(interpreter, "retryWait");
},
},
},
finalize_senddata: { on: { TICK: "done" } },
done: {
meta: {
test: ({
transactionDone,
actualResult,
expectedResult,
actualError,
expectedError,
}: TestContext) => {
if (expectedResult) {
expect(transactionDone).toBe(true);
expect(actualResult).toBe(expectedResult);
} else if (expectedError) {
assertZWaveError(actualError, {
messageMatches: expectedError,
});
}
},
},
},
},
},
{
guards: {
mayRetry: (ctx) => ctx.retryCount < ctx.maxRetries,
},
},
);
const testModel = createModel<TestContext, TestMachineContext>(
testMachine,
).withEvents({
CREATE: {
// test all possible combinations of transactions
cases: [
{ message: "GetControllerIdRequest", target: "execute_simple" },
{ message: "BasicSet", target: "execute_senddata" },
{ message: "BasicGet", target: "execute_get" },
],
},
TICK: {
exec: () => {
jest.advanceTimersByTime(1);
},
},
RETRY_TIMEOUT: {
exec: () => {
jest.advanceTimersByTime(500);
},
},
SUCCESS_SIMPLE: {
exec: (context) => {
const { interpreter, testMessages } = context;
const result = testMessages.GetControllerIdResponse;
context.expectedResult = result;
interpreter.send({
type: "command_success",
result,
} as any);
},
},
FAILURE_SIMPLE: {
exec: (context) => {
const { interpreter } = context;
context.expectedResult = undefined;
context.expectedError = "Timeout while waiting for an ACK";
interpreter.send({
type: "command_failure",
reason: "ACK timeout",
} as any);
},
},
SUCCESS_SENDDATA: {
exec: (context) => {
const { interpreter, forwardedMessages, fakeDriver } = context;
const sentMessage =
forwardedMessages[forwardedMessages.length - 1];
const result = new SendDataRequestTransmitReport(fakeDriver, {
callbackId: sentMessage.callbackId,
transmitStatus: TransmitStatus.OK,
});
context.expectedResult = result;
interpreter.send({
type: "command_success",
result,
} as any);
},
},
FAILURE_SENDDATA: {
exec: (context) => {
const { interpreter } = context;
context.expectedResult = undefined;
context.expectedError = "Failed to send";
interpreter.send({
type: "command_failure",
reason: "response NOK",
} as any);
},
},
// FAILURE_SIMPLE: {
// exec: ({ interpreter, testTransactions }) => {
// interpreter.send({
// type: "command_failure",
// transaction: testTransactions.GetControllerIdRequest,
// reason: "CAN",
// } as any);
// },
// },
// COMMAND_SUCCESS: {
// exec: ({ interpreter }) => {
// interpreter.send({
// type: "command_success",
// message: dummyResponseOK,
// });
// },
// },
// COMMAND_FAILURE: {
// exec: ({ interpreter }) => {
// interpreter.send({ type: "message", message: dummyResponseOK });
// },
// },
});
const testPlans = testModel.getSimplePathPlans();
testPlans.forEach((plan) => {
if (plan.state.value === "init") 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 CREATE ({"message":"BasicSet","target":"execute_senddata"}) → TICK → FAILURE_SENDDATA → RETRY_TIMEOUT → TICK → FAILURE_SENDDATA → RETRY_TIMEOUT → TICK → FAILURE_SENDDATA → TICK`,
// )
// ) {
// return;
// }
it(path.description, () => {
let context: TestContext;
// 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 fakeDriver =
createEmptyMockDriver() as unknown as Driver;
const ctrlrIdRequest = new GetControllerIdRequest(
fakeDriver,
);
const ctrlrIdResponse = new GetControllerIdResponse(
fakeDriver,
{
data: Buffer.from("01080120dc3452b301de", "hex"),
},
);
const sendDataBasicGet = new SendDataRequest(fakeDriver, {
command: new BasicCCGet(fakeDriver, {
nodeId: 2,
}),
maxSendAttempts: 3,
});
const sendDataBasicReport = new ApplicationCommandRequest(
fakeDriver,
{
command: new BasicCCReport(fakeDriver, {
nodeId: 2,
currentValue: 50,
}),
},
);
const sendDataBasicSet = new SendDataRequest(fakeDriver, {
command: new BasicCCSet(fakeDriver, {
nodeId: 2,
targetValue: 22,
}),
maxSendAttempts: 3,
});
const testMessages = {
GetControllerIdRequest: ctrlrIdRequest,
GetControllerIdResponse: ctrlrIdResponse,
BasicSet: sendDataBasicSet,
BasicGet: sendDataBasicGet,
BasicReport: sendDataBasicReport,
};
const testCase = JSON.parse(match.groups.json);
const message = (testMessages as any)[
testCase.message
] as Message;
const { generator, resultPromise } = createMessageGenerator(
fakeDriver,
message,
() => void 0,
);
const transaction = new Transaction(fakeDriver, {
message,
parts: generator,
promise: resultPromise,
priority: MessagePriority.Normal,
});
const implementations: MockImplementations = {
notifyRetry: jest.fn(),
rejectTransaction: jest
.fn()
.mockImplementation((t, e) => {
resultPromise.reject(e);
}),
};
const machine = createTransactionMachine(
"T1",
transaction,
implementations as any,
);
const wrapper = createWrapperMachine(machine);
// eslint-disable-next-line prefer-const
context = {
fakeDriver,
interpreter: interpret(wrapper),
testMessages,
forwardedMessages: [],
transactionDone: false,
};
context.interpreter.onEvent((evt: any) => {
if (evt.type === "forward" && evt.to === "QUEUE") {
context.forwardedMessages.push(
evt.payload.transaction.message,
);
} else if (evt.type === "transaction_done") {
context.transactionDone = true;
}
});
resultPromise
.then((result) => {
context.actualResult = result;
})
.catch((err) => {
context.actualError = err;
});
// .onDone((evt) => {
// context.machineResult = evt.data;
// });
// context.interpreter.onTransition((state) => {
// if (state.changed) console.log(state.value);
// });
context.interpreter.start();
return path.test(context);
});
});
});
});
it("coverage", () => {
testModel.testCoverage({
filter: (stateNode) => {
return !!stateNode.meta;
},
});
});
});