UNPKG

inventoresed

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

480 lines (459 loc) 11.8 kB
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; }, }); }); });