@@ -418,4 +418,225 @@ void statePersistence_stateIsSavedAfterOperation() throws Exception {
418418 }
419419
420420 // endregion
421+
422+ // region Re-entrant dispatch tests
423+
424+ /**
425+ * Entity that uses dispatch() to compose operations re-entrantly.
426+ */
427+ static class BonusDepositEntity extends TaskEntity <Integer > {
428+
429+ public void deposit (int amount ) {
430+ this .state += amount ;
431+ }
432+
433+ public void depositWithBonus (int amount ) {
434+ // Re-entrant: calls deposit() twice on the same entity
435+ dispatch ("deposit" , amount ); // main deposit
436+ dispatch ("deposit" , amount / 10 ); // 10% bonus
437+ }
438+
439+ public int get () {
440+ return this .state ;
441+ }
442+
443+ @ Override
444+ protected Integer initializeState (TaskEntityOperation operation ) {
445+ return 0 ;
446+ }
447+
448+ @ Override
449+ protected Class <Integer > getStateType () {
450+ return Integer .class ;
451+ }
452+ }
453+
454+ /**
455+ * Entity that uses dispatch() with a typed return value.
456+ */
457+ static class ComputeEntity extends TaskEntity <Integer > {
458+
459+ public int double_value (int input ) {
460+ return input * 2 ;
461+ }
462+
463+ public int quadruple (int input ) {
464+ // dispatch → double → then dispatch → double again
465+ int doubled = dispatch ("double_value" , input , int .class );
466+ return dispatch ("double_value" , doubled , int .class );
467+ }
468+
469+ @ Override
470+ protected Integer initializeState (TaskEntityOperation operation ) {
471+ return 0 ;
472+ }
473+
474+ @ Override
475+ protected Class <Integer > getStateType () {
476+ return Integer .class ;
477+ }
478+ }
479+
480+ /**
481+ * Entity that uses dispatch() to call an implicit "delete" operation.
482+ */
483+ static class SelfDeletingEntity extends TaskEntity <String > {
484+
485+ public String resetAndDelete () {
486+ this .state = "resetting" ;
487+ dispatch ("delete" );
488+ return "deleted" ;
489+ }
490+
491+ @ Override
492+ protected String initializeState (TaskEntityOperation operation ) {
493+ return "initial" ;
494+ }
495+
496+ @ Override
497+ protected Class <String > getStateType () {
498+ return String .class ;
499+ }
500+ }
501+
502+ /**
503+ * Entity that tests dispatch() to state-dispatched methods.
504+ */
505+ static class StateDispatchWithReentrancy extends TaskEntity <MyState > {
506+
507+ public StateDispatchWithReentrancy () {
508+ setAllowStateDispatch (true );
509+ }
510+
511+ public int getAndIncrement () {
512+ int before = this .state .getValue ();
513+ dispatch ("increment" ); // dispatches to MyState.increment() via state dispatch
514+ return before ;
515+ }
516+
517+ @ Override
518+ protected Class <MyState > getStateType () {
519+ return MyState .class ;
520+ }
521+ }
522+
523+ @ Test
524+ void dispatch_basicReentrantCall () throws Exception {
525+ BonusDepositEntity entity = new BonusDepositEntity ();
526+ TaskEntityOperation op = createOperation ("depositWithBonus" , 100 );
527+ entity .runAsync (op );
528+
529+ // 100 + 10% bonus = 110
530+ assertEquals (110 , entity .state );
531+ }
532+
533+ @ Test
534+ void dispatch_withTypedReturnValue () throws Exception {
535+ ComputeEntity entity = new ComputeEntity ();
536+ TaskEntityOperation op = createOperation ("quadruple" , 5 );
537+ Object result = entity .runAsync (op );
538+
539+ // 5 * 2 = 10, then 10 * 2 = 20
540+ assertEquals (20 , result );
541+ }
542+
543+ @ Test
544+ void dispatch_implicitDelete () throws Exception {
545+ SelfDeletingEntity entity = new SelfDeletingEntity ();
546+ DataConverter converter = new JacksonDataConverter ();
547+ String serializedState = converter .serialize ("existing" );
548+
549+ TaskEntityOperation op = createOperation ("resetAndDelete" , null , serializedState );
550+ Object result = entity .runAsync (op );
551+
552+ assertEquals ("deleted" , result );
553+ assertNull (entity .state );
554+ }
555+
556+ @ Test
557+ void dispatch_caseInsensitive () throws Exception {
558+ BonusDepositEntity entity = new BonusDepositEntity ();
559+ // "deposit" is the method, but dispatch uses "DEPOSIT" internally — let's validate
560+ // by calling depositWithBonus which dispatches "deposit"
561+ TaskEntityOperation op = createOperation ("DEPOSITWITHBONUS" , 100 );
562+ entity .runAsync (op );
563+ assertEquals (110 , entity .state );
564+ }
565+
566+ @ Test
567+ void dispatch_toStateDispatchedMethod () throws Exception {
568+ StateDispatchWithReentrancy entity = new StateDispatchWithReentrancy ();
569+ DataConverter converter = new JacksonDataConverter ();
570+ String serializedState = converter .serialize (new MyState ());
571+
572+ TaskEntityOperation op = createOperation ("getAndIncrement" , null , serializedState );
573+ Object result = entity .runAsync (op );
574+
575+ // Before increment was 0
576+ assertEquals (0 , result );
577+ // State should now be 1 after dispatch("increment")
578+ assertEquals (1 , entity .state .getValue ());
579+ }
580+
581+ @ Test
582+ void dispatch_unknownOperation_throwsException () throws Exception {
583+ BonusDepositEntity entity = new BonusDepositEntity ();
584+ // First trigger runAsync to set the context on the entity, then test dispatch failure
585+ // We use a custom entity that dispatches an unknown operation
586+ TaskEntity <Void > failEntity = new TaskEntity <Void >() {
587+ public void bad () {
588+ dispatch ("nonExistent" );
589+ }
590+
591+ @ Override
592+ protected Class <Void > getStateType () {
593+ return null ;
594+ }
595+ };
596+
597+ assertThrows (UnsupportedOperationException .class , () -> {
598+ failEntity .runAsync (createOperation ("bad" ));
599+ });
600+ }
601+
602+ @ Test
603+ void dispatch_outsideExecution_throwsIllegalState () {
604+ BonusDepositEntity entity = new BonusDepositEntity ();
605+ // dispatch() called without runAsync() first (no context set)
606+ assertThrows (IllegalStateException .class , () -> {
607+ entity .dispatch ("deposit" , 10 );
608+ });
609+ }
610+
611+ @ Test
612+ void dispatch_noInputOverload () throws Exception {
613+ CounterEntity entity = new CounterEntity ();
614+ // Use a wrapper entity that dispatches "reset" with no input
615+ TaskEntity <Integer > resetDispatcher = new TaskEntity <Integer >() {
616+ public void doReset () {
617+ this .state = 42 ;
618+ dispatch ("reset" ); // reset sets state to 0
619+ }
620+
621+ public void reset () {
622+ this .state = 0 ;
623+ }
624+
625+ @ Override
626+ protected Integer initializeState (TaskEntityOperation operation ) {
627+ return 0 ;
628+ }
629+
630+ @ Override
631+ protected Class <Integer > getStateType () {
632+ return Integer .class ;
633+ }
634+ };
635+
636+ TaskEntityOperation op = createOperation ("doReset" );
637+ resetDispatcher .runAsync (op );
638+ assertEquals (0 , resetDispatcher .state );
639+ }
640+
641+ // endregion
421642}
0 commit comments