import { createModel } from "@xstate/test" ; import { createDeferredPromise , DeferredPromise , } from "alcalzone-shared/deferred-promise" ; import { assign , interpret , Machine , State } from "xstate" ; import type { Message } from "../message/Message" ; import { dummyCallbackNOK , dummyCallbackOK , dummyCallbackPartial , dummyMessageNoResponseNoCallback , dummyMessageNoResponseWithCallback , dummyMessageUnrelated , dummyMessageWithResponseNoCallback , dummyMessageWithResponseWithCallback , dummyResponseNOK , dummyResponseOK , } from "../test/messages" ; import { createSerialAPICommandMachine , SerialAPICommandDoneData , SerialAPICommandInterpreter , SerialAPICommandMachineParams , } from "./SerialAPICommandMachine" ; interface TestMachineStateSchema { states : { init : { } ; sending : { } ; waitForACK : { } ; waitForResponse : { } ; waitForCallback : { } ; unsolicited : { } ; success : { } ; failure : { } ; } ; } 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 = { 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 } , } , state ) => { const att = state . context . attempt ; if ( att > 1 ) { expect ( interpreter . state . value ) . toBe ( "retryWait" ) ; jest . advanceTimersByTime ( 100 + ( att - 1 ) * 1000 ) ; } expect ( interpreter . state . value ) . toBe ( "sending" ) ; 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 } ) => { expect ( respondedUnsolicited ) . toBeTrue ( ) ; } } } , success : { meta : { test : ( { interpreter , expectedResult , machineResult , } ) => { expect ( interpreter . state . value ) . toBe ( "success" ) ; expect ( machineResult ) . toBeObject ( ) ; expect ( machineResult ) . toMatchObject ( { type : "success" , result : expectedResult } ) ; } } } , failure : { meta : { test : ( { interpreter , expectedFailureReason , machineResult , implementations : { sendData } , } ) => { expect ( interpreter . state . value ) . toBe ( "failure" ) ; expect ( machineResult ) . toBeObject ( ) ; expect ( machineResult ) . toMatchObject ( { type : "failure" , reason : expectedFailureReason } ) ; 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 : { 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 , ( ) => { let context ; 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 } ; const createMachineRegex = /CREATE \((?<json>[^\)]+)\)/ ; const match = createMachineRegex . exec ( path . description ) ; if ( ! match ?. groups ?. json ) return path . test ( undefined as any ) ; const msg = JSON . parse ( match . groups . json ) ; const machine = createSerialAPICommandMachine ( 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 . start ( ) ; if ( plan . state . value === "failure" ) { 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" ) { 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 ; } } ) ; } ) ; } ) ;