UNPKG

inventoresed

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

452 lines (418 loc) 11.6 kB
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; }, }); }); });