UNPKG

inventoresed

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

446 lines (420 loc) 11.3 kB
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; }, }); }); });