diff --git a/src/Behavior/EventBehavior.php b/src/Behavior/EventBehavior.php index 07a8468..964709a 100644 --- a/src/Behavior/EventBehavior.php +++ b/src/Behavior/EventBehavior.php @@ -83,4 +83,14 @@ public function actor(ContextManager $context): mixed { return $this->actor; } + + /** + * Retrieves the scenario value from the payload. + * + * @return string|null The scenario value if available, otherwise null. + */ + public function getScenario(): ?string + { + return $this->payload['scenario'] ?? null; + } } diff --git a/src/Definition/MachineDefinition.php b/src/Definition/MachineDefinition.php index 4e552ae..17d17f9 100644 --- a/src/Definition/MachineDefinition.php +++ b/src/Definition/MachineDefinition.php @@ -57,6 +57,9 @@ class MachineDefinition /** The initial state definition for this machine definition. */ public ?StateDefinition $initialStateDefinition = null; + /** Indicates whether the scenario is enabled. */ + public bool $scenariosEnabled = false; + // endregion // region Constructor @@ -75,9 +78,18 @@ private function __construct( public ?array $behavior, public string $id, public ?string $version, + public ?array $scenarios, public string $delimiter = self::STATE_DELIMITER, ) { + $this->scenariosEnabled = isset($this->config['scenarios_enabled']) && $this->config['scenarios_enabled'] === true; + $this->root = $this->createRootStateDefinition($config); + + // Checks if the scenario is enabled, and if true, creates scenario state definitions. + if ($this->scenariosEnabled) { + $this->createScenarioStateDefinitions(); + } + $this->root->initializeTransitions(); $this->stateDefinitions = $this->root->stateDefinitions; @@ -107,12 +119,14 @@ private function __construct( public static function define( array $config = null, array $behavior = null, + array $scenarios = null, ): self { return new self( config: $config ?? null, behavior: array_merge(self::initializeEmptyBehavior(), $behavior ?? []), id: $config['id'] ?? self::DEFAULT_ID, version: $config['version'] ?? null, + scenarios: $scenarios, delimiter: $config['delimiter'] ?? self::STATE_DELIMITER, ); } @@ -159,15 +173,38 @@ protected function createRootStateDefinition(?array $config): StateDefinition ); } + /** + * Creates scenario state definitions based on the defined scenarios. + * + * This method iterates through the specified scenarios and creates StateDefinition objects + * for each, with the provided states configuration. + */ + protected function createScenarioStateDefinitions(): void + { + if (!empty($this->scenarios)) { + foreach ($this->scenarios as $name => $scenarios) { + $parentStateDefinition = reset($this->idMap); + $state = new StateDefinition( + config: ['states' => $scenarios], + options: [ + 'parent' => $parentStateDefinition, + 'machine' => $this, + 'key' => $name, + ] + ); + + $state->initializeTransitions(); + } + } + } + /** * Build the initial state for the machine. * * @return ?State The initial state of the machine. */ - public function getInitialState(): ?State + public function getInitialState(EventBehavior|array $event = null): ?State { - $initialStateDefinition = $this->root->findInitialStateDefinition(); - if (is_null($this->initialStateDefinition)) { return null; } @@ -179,6 +216,9 @@ public function getInitialState(): ?State currentStateDefinition: $this->initialStateDefinition, ); + $initialState = $this->getScenarioStateIfAvailable(state: $initialState, eventBehavior: $eventBehavior ?? null); + $this->initialStateDefinition = $initialState->currentStateDefinition; + // Record the internal machine init event. $initialState->setInternalEventBehavior(type: InternalEvent::MACHINE_START); @@ -194,8 +234,8 @@ public function getInitialState(): ?State eventBehavior: $initialState->currentEventBehavior, ); - if ($initialStateDefinition?->transitionDefinitions !== null) { - foreach ($initialStateDefinition->transitionDefinitions as $transition) { + if ($this->initialStateDefinition?->transitionDefinitions !== null) { + foreach ($this->initialStateDefinition->transitionDefinitions as $transition) { if ($transition->isAlways === true) { return $this->transition( event: [ @@ -227,6 +267,40 @@ public function getInitialState(): ?State return $initialState; } + /** + * Retrieves the scenario state if scenario is enabled and available; otherwise, returns the current state. + * + * @param State $state The current state. + * @param EventBehavior|array|null $eventBehavior The optional event behavior or event data. + * + * @return State|null The scenario state if scenario is enabled and found, otherwise returns the current state. + */ + public function getScenarioStateIfAvailable(State $state, EventBehavior|array $eventBehavior = null): ?State + { + if ($this->scenariosEnabled === false) { + return $state; + } + + if ($eventBehavior !== null) { + // Initialize the event and validate it + $eventBehavior = $this->initializeEvent($eventBehavior, $state); + if ($eventBehavior->getScenario() !== null) { + $state->context->set('scenario', $eventBehavior->getScenario()); + } + } + + $scenarioStateKey = str_replace($this->id, $this->id.$this->delimiter.$state->context->get('scenario'), $state->currentStateDefinition->id); + if ($state->context->has('scenario') && isset($this->idMap[$scenarioStateKey])) { + return $this->buildCurrentState( + context: $state->context, + currentStateDefinition: $this->idMap[$scenarioStateKey], + eventBehavior: $eventBehavior + ); + } + + return $state; + } + /** * Builds the current state of the state machine. * @@ -484,8 +558,12 @@ public function transition( EventBehavior|array $event, State $state = null ): State { - // Use the initial state if no state is provided - $state ??= $this->getInitialState(); + if ($state !== null) { + $state = $this->getScenarioStateIfAvailable(state: $state, eventBehavior: $event); + } else { + // Use the initial state if no state is provided + $state = $this->getInitialState(event: $event); + } $currentStateDefinition = $this->getCurrentStateDefinition($state); @@ -549,6 +627,9 @@ public function transition( $newState = $state ->setCurrentStateDefinition($targetStateDefinition ?? $currentStateDefinition); + // Get scenario state if exists + $newState = $this->getScenarioStateIfAvailable(state: $newState, eventBehavior: $eventBehavior); + // Record state enter event $state->setInternalEventBehavior( type: InternalEvent::STATE_ENTER, diff --git a/tests/ScenarioTest.php b/tests/ScenarioTest.php new file mode 100644 index 0000000..99e5fda --- /dev/null +++ b/tests/ScenarioTest.php @@ -0,0 +1,25 @@ +send(['type' => 'EVENT_B', 'payload' => ['scenario' => 'test']]); + + expect($state) + ->toBeInstanceOf(State::class) + ->and($state->value)->toBe([MachineDefinition::DEFAULT_ID.MachineDefinition::STATE_DELIMITER.'test.stateC']) + ->and($state->context->count)->toBe(0); + + $state = $machine->send('EVENT_D'); + + expect($state) + ->toBeInstanceOf(State::class) + ->and($state->value)->toBe([MachineDefinition::DEFAULT_ID.MachineDefinition::STATE_DELIMITER.'test.stateA']) + ->and($state->context->count)->toBe(-1); +}); diff --git a/tests/Stubs/Machines/MachineWithScenarios.php b/tests/Stubs/Machines/MachineWithScenarios.php new file mode 100644 index 0000000..3699686 --- /dev/null +++ b/tests/Stubs/Machines/MachineWithScenarios.php @@ -0,0 +1,79 @@ + 'stateA', + 'scenarios_enabled' => true, + 'context' => [ + 'count' => 1, + ], + 'states' => [ + 'stateA' => [ + 'on' => [ + 'EVENT_B' => [ + 'target' => 'stateB', + 'actions' => 'incrementAction', + ], + ], + ], + 'stateB' => [ + 'on' => [ + 'EVENT_C' => 'stateC', + ], + ], + 'stateC' => [ + 'on' => [ + 'EVENT_D' => [ + 'target' => 'stateD', + 'actions' => 'incrementAction', + ], + ], + ], + 'stateD' => [], + ], + ], + behavior: [ + 'actions' => [ + 'incrementAction' => function (ContextManager $context): void { + $context->set('count', $context->get('count') + 1); + }, + 'decrementAction' => function (ContextManager $context): void { + $context->set('count', $context->get('count') - 1); + }, + ], + ], + scenarios: [ + 'test' => [ + 'stateA' => [ + 'on' => [ + 'EVENT_B' => [ + 'target' => 'stateC', + 'actions' => 'decrementAction', + ], + ], + ], + 'stateC' => [ + 'on' => [ + 'EVENT_D' => [ + 'target' => 'stateA', + 'actions' => 'decrementAction', + ], + ], + ], + ], + ] + ); + } +}