import { createModel } from "@xstate/test" ; import { SecurityManager } from "@zwave-js/core" ; import { createDeferredPromise } from "alcalzone-shared/deferred-promise" ; import { assign , interpret , Machine , State } from "xstate" ; import { BasicCCGet , BasicCCReport , BasicCCSet } from "../commandclass/BasicCC" ; import { SendDataAbort , SendDataRequest } from "../controller/SendDataMessages" ; import { MessagePriority } from "../message/Constants" ; import type { Message } from "../message/Message" ; 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" ; interface TestMachineStateSchema { states : { idle : { } ; sending : { } ; aborting : { } ; maybeDone : { } ; done : { } ; } ; } interface TestMachineContext { transactions ? : Transaction [ ] ; index : number ; expectedCalls : number ; definitelyDone : boolean ; } type TestMachineEvents = { type : "ADD" ; transaction : Transaction ; } | { 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 ) { 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 ( ( 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" } , ] } , meta : { test : async ( { expectedTransactions } , state ) => { 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 ( _ , state ) => { 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 , } ) => { expect ( interpreter . children . get ( "child" ) ! . state . value ) . toBe ( "idle" ) ; expect ( actualResults ) . toContainAllValues ( expectedResults ) ; expect ( actualReasons ) . toContainAllValues ( expectedReasons ) ; } } } } } , { guards : { definitelyDone : ( ctx ) => ctx . definitelyDone , queueEmpty : ( ctx ) => { return ctx . index + 1 >= ( ctx . transactions ?. length ?? 0 ) ; } , isCbTimeout : ( _ , evt ) => 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" , transaction : testTransactions [ cmd ] , from : "T1" } ) ; } } , cases : [ { commands : [ "BasicGet" ] } , { commands : [ "BasicSet" , "BasicGet" ] } , ] } , ABORT : { exec : ( { interpreter } ) => { interpreter . send ( { type : "remove" , transaction : interpreter . state . context . currentTransaction } ) ; } } , API_SUCCESS : { exec : ( { interpreter } , event ) => { interpreter . send ( { type : "done.invoke.execute" , data : { type : "success" , result : testTransactions [ event . command ] . message } } as any ) ; } , cases : [ { command : "BasicReport" } ] } , API_FAILED : { exec : ( { interpreter } , event ) => { interpreter . send ( { type : "done.invoke.execute" , data : { type : "failure" , reason : event . reason , 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" , reason : event . reason , 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 ; const planDescription = plan . description . replace ( ` (${ JSON . stringify ( plan . state . context ) })` , "" ) ; describe ( planDescription , ( ) => { plan . paths . forEach ( ( path ) => { 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 ) . 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 ] , [ ] ) . map ( ( cmd ) => testTransactions [ cmd ] ) ; const wrapper = createWrapperMachine ( machine ) ; const context = { interpreter : interpret ( wrapper ) , actualResults : [ ] , expectedResults , actualReasons : [ ] , expectedReasons , expectedTransactions } ; context . interpreter . onEvent ( ( evt ) => { 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 . start ( ) ; return path . test ( context ) ; } ) ; } ) ; } ) ; } ) ; it ( "coverage" , ( ) => { testModel . testCoverage ( { filter : ( stateNode ) => { return ! ! stateNode . meta ; } } ) ; } ) ; } ) ;