From 5d1b2d1fb222ecda1df50f73c8cefa9f9190b7a7 Mon Sep 17 00:00:00 2001 From: Ben Rowe Date: Thu, 11 Dec 2025 18:36:23 +1100 Subject: [PATCH 01/10] Add delta interface --- src/Delta.php | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/Delta.php diff --git a/src/Delta.php b/src/Delta.php new file mode 100644 index 0000000..e284ca5 --- /dev/null +++ b/src/Delta.php @@ -0,0 +1,34 @@ + + */ + public function asArray(): array; +} From dc274cad7ecfe497cf217f320abd250a25b92774 Mon Sep 17 00:00:00 2001 From: Ben Rowe Date: Thu, 11 Dec 2025 18:37:02 +1100 Subject: [PATCH 02/10] Implement Delta as ArrayDelta for a simple implementation --- src/ArrayDelta.php | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/ArrayDelta.php diff --git a/src/ArrayDelta.php b/src/ArrayDelta.php new file mode 100644 index 0000000..9262c8a --- /dev/null +++ b/src/ArrayDelta.php @@ -0,0 +1,45 @@ + $changes + */ + public function __construct(private array $changes = []) + { + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->changes); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->changes[$key] ?? $default; + } + + public function asArray(): array + { + return $this->changes; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->changes; + } +} From 9717366084e83b9d6035cad06a0cf825b53feb41 Mon Sep 17 00:00:00 2001 From: Ben Rowe Date: Thu, 11 Dec 2025 18:38:47 +1100 Subject: [PATCH 03/10] Update existing method signatures with Delta interface --- src/Action/ActionContext.php | 6 ++---- .../CallableConfigurationProvider.php | 5 +++-- src/Configuration/ConfigurationProvider.php | 6 ++---- src/Events/TransitionStarting.php | 6 ++---- src/Gate/GateContext.php | 6 ++---- src/Locking/LockKeyProvider.php | 5 +++-- src/StateFlow.php | 10 ++-------- src/TransitionContext.php | 14 ++++---------- 8 files changed, 20 insertions(+), 38 deletions(-) diff --git a/src/Action/ActionContext.php b/src/Action/ActionContext.php index 6a4890d..f063906 100644 --- a/src/Action/ActionContext.php +++ b/src/Action/ActionContext.php @@ -4,17 +4,15 @@ namespace BenRowe\StateFlow\Action; +use BenRowe\StateFlow\Delta; use BenRowe\StateFlow\State; use BenRowe\StateFlow\TransitionContext; readonly class ActionContext { - /** - * @param array $desiredDelta - */ public function __construct( public State $currentState, - public array $desiredDelta, + public Delta $desiredDelta, public TransitionContext $executionContext, ) { } diff --git a/src/Configuration/CallableConfigurationProvider.php b/src/Configuration/CallableConfigurationProvider.php index a257e28..5ba3b61 100644 --- a/src/Configuration/CallableConfigurationProvider.php +++ b/src/Configuration/CallableConfigurationProvider.php @@ -4,19 +4,20 @@ namespace BenRowe\StateFlow\Configuration; +use BenRowe\StateFlow\Delta; use BenRowe\StateFlow\State; use Closure; readonly class CallableConfigurationProvider implements ConfigurationProvider { /** - * @param Closure(State, array): Configuration $callable + * @param Closure(State, Delta): Configuration $callable */ public function __construct(private Closure $callable) { } - public function provide(State $currentState, array $desiredDelta): Configuration + public function provide(State $currentState, Delta $desiredDelta): Configuration { return ($this->callable)($currentState, $desiredDelta); } diff --git a/src/Configuration/ConfigurationProvider.php b/src/Configuration/ConfigurationProvider.php index eca1071..72114c8 100644 --- a/src/Configuration/ConfigurationProvider.php +++ b/src/Configuration/ConfigurationProvider.php @@ -4,12 +4,10 @@ namespace BenRowe\StateFlow\Configuration; +use BenRowe\StateFlow\Delta; use BenRowe\StateFlow\State; interface ConfigurationProvider { - /** - * @param array $desiredDelta - */ - public function provide(State $currentState, array $desiredDelta): Configuration; + public function provide(State $currentState, Delta $desiredDelta): Configuration; } diff --git a/src/Events/TransitionStarting.php b/src/Events/TransitionStarting.php index 891c06f..afab0b1 100644 --- a/src/Events/TransitionStarting.php +++ b/src/Events/TransitionStarting.php @@ -4,16 +4,14 @@ namespace BenRowe\StateFlow\Events; +use BenRowe\StateFlow\Delta; use BenRowe\StateFlow\State; class TransitionStarting extends Event { - /** - * @param array $desiredDelta - */ public function __construct( public State $currentState, - public array $desiredDelta, + public Delta $desiredDelta, ) { parent::__construct(); } diff --git a/src/Gate/GateContext.php b/src/Gate/GateContext.php index b738f71..5675412 100644 --- a/src/Gate/GateContext.php +++ b/src/Gate/GateContext.php @@ -4,14 +4,12 @@ namespace BenRowe\StateFlow\Gate; +use BenRowe\StateFlow\Delta; use BenRowe\StateFlow\State; readonly class GateContext { - /** - * @param array $desiredDelta - */ - public function __construct(public State $currentState, public array $desiredDelta) + public function __construct(public State $currentState, public Delta $desiredDelta) { } } diff --git a/src/Locking/LockKeyProvider.php b/src/Locking/LockKeyProvider.php index d295d65..a8bdda0 100644 --- a/src/Locking/LockKeyProvider.php +++ b/src/Locking/LockKeyProvider.php @@ -4,6 +4,7 @@ namespace BenRowe\StateFlow\Locking; +use BenRowe\StateFlow\Delta; use BenRowe\StateFlow\State; /** @@ -15,8 +16,8 @@ interface LockKeyProvider * Generate a lock key for the given state and desired delta * * @param State $state The current state - * @param array $desiredDelta The desired changes + * @param Delta $desiredDelta The desired changes * @return string The lock key */ - public function getLockKey(State $state, array $desiredDelta): string; + public function getLockKey(State $state, Delta $desiredDelta): string; } diff --git a/src/StateFlow.php b/src/StateFlow.php index a53f10e..36b3651 100644 --- a/src/StateFlow.php +++ b/src/StateFlow.php @@ -32,10 +32,7 @@ public function __construct( $this->eventDispatcher = $eventDispatcher ?? new NullEventDispatcher(); } - /** - * @param array $delta - */ - public function transition(State $currentState, array $delta): StateWorker + public function transition(State $currentState, Delta $delta): StateWorker { $this->eventDispatcher->dispatch(new TransitionStarting($currentState, $delta)); @@ -75,10 +72,7 @@ public function fromContext(TransitionContext $context): StateWorker return $worker; } - /** - * @param array $delta - */ - private function resolveConfig(State $currentState, array $delta): Configuration + private function resolveConfig(State $currentState, Delta $delta): Configuration { return $this ->resolveProvider() diff --git a/src/TransitionContext.php b/src/TransitionContext.php index 5cdc347..0a06dd9 100644 --- a/src/TransitionContext.php +++ b/src/TransitionContext.php @@ -38,12 +38,9 @@ class TransitionContext private bool $skippedDueToLock = false; - /** - * @param array $desiredDelta - */ public function __construct( private readonly State $initialState, - private readonly array $desiredDelta, + private readonly Delta $desiredDelta, private readonly Configuration $configuration, ) { $this->currentState = $initialState; @@ -70,10 +67,7 @@ public function updateCurrentState(State $newState): void $this->currentState = $newState; } - /** - * @return array - */ - public function getDesiredDelta(): array + public function getDesiredDelta(): Delta { return $this->desiredDelta; } @@ -220,7 +214,7 @@ public function serialize(): string $data = [ 'initialState' => $this->initialState->toArray(), 'currentState' => $this->currentState->toArray(), - 'desiredDelta' => $this->desiredDelta, + 'desiredDelta' => $this->desiredDelta->asArray(), 'status' => $this->status?->name, 'skippedDueToLock' => $this->skippedDueToLock, 'lockState' => $this->lockState->toArray(), @@ -292,7 +286,7 @@ public static function unserialize( $configuration = self::restoreConfiguration($decoded, $actionFactory, $gateFactory); // Create new context - $desiredDelta = $decoded['desiredDelta'] ?? []; + $desiredDelta = new ArrayDelta($decoded['desiredDelta'] ?? []); $context = new self($initialState, $desiredDelta, $configuration); // Restore current state From 6695abc2a2649ab88c7247c78bb637a4a0c528a2 Mon Sep 17 00:00:00 2001 From: Ben Rowe Date: Thu, 11 Dec 2025 18:39:07 +1100 Subject: [PATCH 04/10] Add simple unit test for array delta --- tests/Unit/ArrayDeltaTest.php | 122 ++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/Unit/ArrayDeltaTest.php diff --git a/tests/Unit/ArrayDeltaTest.php b/tests/Unit/ArrayDeltaTest.php new file mode 100644 index 0000000..7aaee0d --- /dev/null +++ b/tests/Unit/ArrayDeltaTest.php @@ -0,0 +1,122 @@ + 'active', 'count' => 0]); + + $this->assertTrue($delta->has('status')); + $this->assertTrue($delta->has('count')); + } + + public function testHasReturnsFalseForNonExistentKey(): void + { + $delta = new ArrayDelta(['status' => 'active']); + + $this->assertFalse($delta->has('missing')); + } + + public function testHasReturnsTrueForNullValue(): void + { + $delta = new ArrayDelta(['value' => null]); + + $this->assertTrue($delta->has('value')); + } + + public function testGetReturnsValueForExistingKey(): void + { + $delta = new ArrayDelta(['status' => 'active', 'count' => 42]); + + $this->assertEquals('active', $delta->get('status')); + $this->assertEquals(42, $delta->get('count')); + } + + public function testGetReturnsDefaultForNonExistentKey(): void + { + $delta = new ArrayDelta(['status' => 'active']); + + $this->assertNull($delta->get('missing')); + $this->assertEquals('default', $delta->get('missing', 'default')); + $this->assertEquals(100, $delta->get('missing', 100)); + } + + public function testGetReturnsNullValueCorrectly(): void + { + $delta = new ArrayDelta(['value' => null]); + + $this->assertNull($delta->get('value')); + $this->assertNull($delta->get('value', 'default')); + } + + public function testAsArrayReturnsOriginalArray(): void + { + $data = ['status' => 'active', 'count' => 42, 'tags' => ['foo', 'bar']]; + $delta = new ArrayDelta($data); + + $this->assertEquals($data, $delta->asArray()); + } + + public function testAsArrayReturnsEmptyArrayForEmptyDelta(): void + { + $delta = new ArrayDelta(); + + $this->assertEquals([], $delta->asArray()); + } + + public function testJsonSerializeReturnsArray(): void + { + $data = ['status' => 'active', 'count' => 42]; + $delta = new ArrayDelta($data); + + $this->assertEquals($data, $delta->jsonSerialize()); + } + + public function testJsonEncodeWorks(): void + { + $data = ['status' => 'active', 'count' => 42]; + $delta = new ArrayDelta($data); + + $json = json_encode($delta); + $this->assertEquals('{"status":"active","count":42}', $json); + } + + public function testSupportsNestedArrays(): void + { + $delta = new ArrayDelta([ + 'user' => [ + 'name' => 'John', + 'email' => 'john@example.com', + ], + ]); + + $this->assertTrue($delta->has('user')); + $this->assertEquals(['name' => 'John', 'email' => 'john@example.com'], $delta->get('user')); + } + + public function testSupportsVariousDataTypes(): void + { + $delta = new ArrayDelta([ + 'string' => 'value', + 'int' => 42, + 'float' => 3.14, + 'bool' => true, + 'array' => [1, 2, 3], + 'null' => null, + ]); + + $this->assertEquals('value', $delta->get('string')); + $this->assertEquals(42, $delta->get('int')); + $this->assertEquals(3.14, $delta->get('float')); + $this->assertTrue($delta->get('bool')); + $this->assertEquals([1, 2, 3], $delta->get('array')); + $this->assertNull($delta->get('null')); + } +} From 71b0c9e6935659a688d7dabf34542bfb84a43ca0 Mon Sep 17 00:00:00 2001 From: Ben Rowe Date: Thu, 11 Dec 2025 18:48:22 +1100 Subject: [PATCH 05/10] Update integration tests to use new delta interface --- .../Configuration/ConfigurationTest.php | 36 ++++++++++--------- .../Integration/StateFlow/ActionGuardTest.php | 7 ++-- .../StateFlow/BasicExecutionTest.php | 12 ++++--- .../StateFlow/ComplexWorkflowTest.php | 14 ++++---- .../Integration/StateFlow/DeltaAccessTest.php | 5 +-- .../StateFlow/ErrorHandlingTest.php | 7 ++-- .../StateFlow/EventDispatchingTest.php | 3 +- .../StateFlow/ExecutionControlTest.php | 7 ++-- tests/Integration/StateFlow/LockingTest.php | 25 ++++++------- .../StateFlow/SerializationTest.php | 9 ++--- .../StateFlow/StepByStepExecutionTest.php | 21 +++++------ .../StateFlow/TransitionGateTest.php | 13 +++---- .../StateFlow/WorkflowResumeTest.php | 11 +++--- 13 files changed, 93 insertions(+), 77 deletions(-) diff --git a/tests/Integration/Configuration/ConfigurationTest.php b/tests/Integration/Configuration/ConfigurationTest.php index 186ef62..d751daa 100644 --- a/tests/Integration/Configuration/ConfigurationTest.php +++ b/tests/Integration/Configuration/ConfigurationTest.php @@ -7,8 +7,10 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\CallableConfigurationProvider; use BenRowe\StateFlow\Configuration\Configuration; +use BenRowe\StateFlow\Delta; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateResult; use BenRowe\StateFlow\State; @@ -27,14 +29,14 @@ public function testConfigurationProviderSupportsConditionalGatesBasedOnTransiti $validationGate = $this->createStubGate('ValidationGate', GateResult::ALLOW); $provider = new CallableConfigurationProvider( - function (State $state, array $delta) use ($permissionGate, $validationGate) { + function (State $state, Delta $delta) use ($permissionGate, $validationGate) { // Different gates based on the transition type - if (isset($delta['status']) && $delta['status'] === 'published') { + if ($delta->has('status') && $delta->get('status') === 'published') { // Publishing requires permission check return new Configuration([$permissionGate], []); } - if (isset($delta['content'])) { + if ($delta->has('content')) { // Content changes require validation return new Configuration([$validationGate], []); } @@ -44,17 +46,17 @@ function (State $state, array $delta) use ($permissionGate, $validationGate) { ); // Test publishing transition - $publishConfig = $provider->provide($state, ['status' => 'published']); + $publishConfig = $provider->provide($state, new ArrayDelta(['status' => 'published'])); $this->assertCount(1, $publishConfig->getTransitionGates()); $this->assertSame($permissionGate, $publishConfig->getTransitionGates()[0]); // Test content update transition - $contentConfig = $provider->provide($state, ['content' => 'New content']); + $contentConfig = $provider->provide($state, new ArrayDelta(['content' => 'New content'])); $this->assertCount(1, $contentConfig->getTransitionGates()); $this->assertSame($validationGate, $contentConfig->getTransitionGates()[0]); // Test simple transition - $simpleConfig = $provider->provide($state, ['priority' => 'high']); + $simpleConfig = $provider->provide($state, new ArrayDelta(['priority' => 'high'])); $this->assertCount(0, $simpleConfig->getTransitionGates()); } @@ -68,7 +70,7 @@ public function testConfigurationProviderSupportsConditionalActionsBasedOnState( $updateIndexAction = $this->createStubAction('UpdateSearchIndex'); $provider = new CallableConfigurationProvider( - function (State $state, array $delta) use ( + function (State $state, Delta $delta) use ( $sendNotificationAction, $incrementVersionAction, $updateIndexAction @@ -77,17 +79,17 @@ function (State $state, array $delta) use ( $actions = []; // Always increment version on content changes - if (isset($delta['content'])) { + if ($delta->has('content')) { $actions[] = $incrementVersionAction; } // Send notification when publishing - if ($stateData['status'] === 'draft' && isset($delta['status']) && $delta['status'] === 'published') { + if ($stateData['status'] === 'draft' && $delta->has('status') && $delta->get('status') === 'published') { $actions[] = $sendNotificationAction; } // Update search index for published content - if (isset($delta['status']) && $delta['status'] === 'published') { + if ($delta->has('status') && $delta->get('status') === 'published') { $actions[] = $updateIndexAction; } @@ -96,13 +98,13 @@ function (State $state, array $delta) use ( ); // Test publishing from draft - $publishConfig = $provider->provide($draftState, ['status' => 'published']); + $publishConfig = $provider->provide($draftState, new ArrayDelta(['status' => 'published'])); $this->assertCount(2, $publishConfig->getActions()); $this->assertContains($sendNotificationAction, $publishConfig->getActions()); $this->assertContains($updateIndexAction, $publishConfig->getActions()); // Test content update on published - $contentConfig = $provider->provide($publishedState, ['content' => 'Updated']); + $contentConfig = $provider->provide($publishedState, new ArrayDelta(['content' => 'Updated'])); $this->assertCount(1, $contentConfig->getActions()); $this->assertSame($incrementVersionAction, $contentConfig->getActions()[0]); } @@ -116,20 +118,20 @@ public function testConfigurationProviderSupportsComplexWorkflowScenarios(): voi $action3 = $this->createStubAction('ProcessPayment'); $provider = new CallableConfigurationProvider( - function (State $state, array $delta) use ($gate1, $gate2, $action1, $action2, $action3) { + function (State $state, Delta $delta) use ($gate1, $gate2, $action1, $action2, $action3) { $stateData = $state->toArray(); $gates = []; $actions = []; // Workflow: pending -> approved - if ($stateData['status'] === 'pending' && isset($delta['status']) && $delta['status'] === 'approved') { + if ($stateData['status'] === 'pending' && $delta->has('status') && $delta->get('status') === 'approved') { $gates[] = $gate1; // RequireApproval $actions[] = $action1; // CreateAuditLog $actions[] = $action2; // NotifyApprovers } // Workflow: approved -> paid - if ($stateData['status'] === 'approved' && isset($delta['status']) && $delta['status'] === 'paid') { + if ($stateData['status'] === 'approved' && $delta->has('status') && $delta->get('status') === 'paid') { $gates[] = $gate2; // CheckBudget $actions[] = $action1; // CreateAuditLog $actions[] = $action3; // ProcessPayment @@ -141,7 +143,7 @@ function (State $state, array $delta) use ($gate1, $gate2, $action1, $action2, $ // Test approval workflow $pendingState = $this->createTestState(['status' => 'pending', 'amount' => 1000]); - $approvalConfig = $provider->provide($pendingState, ['status' => 'approved']); + $approvalConfig = $provider->provide($pendingState, new ArrayDelta(['status' => 'approved'])); $this->assertCount(1, $approvalConfig->getTransitionGates()); $this->assertSame($gate1, $approvalConfig->getTransitionGates()[0]); @@ -151,7 +153,7 @@ function (State $state, array $delta) use ($gate1, $gate2, $action1, $action2, $ // Test payment workflow $approvedState = $this->createTestState(['status' => 'approved', 'amount' => 1000]); - $paymentConfig = $provider->provide($approvedState, ['status' => 'paid']); + $paymentConfig = $provider->provide($approvedState, new ArrayDelta(['status' => 'paid'])); $this->assertCount(1, $paymentConfig->getTransitionGates()); $this->assertSame($gate2, $paymentConfig->getTransitionGates()[0]); diff --git a/tests/Integration/StateFlow/ActionGuardTest.php b/tests/Integration/StateFlow/ActionGuardTest.php index 916b5e5..b5dbc72 100644 --- a/tests/Integration/StateFlow/ActionGuardTest.php +++ b/tests/Integration/StateFlow/ActionGuardTest.php @@ -4,6 +4,7 @@ namespace BenRowe\StateFlow\Tests\Integration\StateFlow; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Gate\GateResult; use BenRowe\StateFlow\StateFlow; @@ -40,7 +41,7 @@ public function testActionWithGateThatAllows(): void $stateFlow = new StateFlow(fn () => new Configuration([], [$action])); $context = $stateFlow - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); // Verify the action's gate was evaluated @@ -70,7 +71,7 @@ public function testActionWithGateThatDenies(): void $stateFlow = new StateFlow(fn () => new Configuration([], [$action])); $context = $stateFlow - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); // Verify the action's gate was evaluated @@ -104,7 +105,7 @@ public function testMultipleActionsWithIndividualGates(): void $stateFlow = new StateFlow(fn () => new Configuration([], [$action1, $action2, $action3])); $context = $stateFlow - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); // Verify all 3 action gates were evaluated diff --git a/tests/Integration/StateFlow/BasicExecutionTest.php b/tests/Integration/StateFlow/BasicExecutionTest.php index 24e28c5..8f94397 100644 --- a/tests/Integration/StateFlow/BasicExecutionTest.php +++ b/tests/Integration/StateFlow/BasicExecutionTest.php @@ -7,7 +7,9 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionResult; use BenRowe\StateFlow\Action\ExecutionState; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; +use BenRowe\StateFlow\Delta; use BenRowe\StateFlow\Gate\GateResult; use BenRowe\StateFlow\State; use BenRowe\StateFlow\StateFlow; @@ -38,7 +40,7 @@ protected function setUp(): void */ public function testCanExecuteSimpleConfiguration(): void { - $stateFlow = new StateFlow(function (State $state, array $delta): Configuration { + $stateFlow = new StateFlow(function (State $state, Delta $delta): Configuration { $action1 = $this ->createMock(Action::class); $action1->method('execute')->willReturnCallback(function () { @@ -48,7 +50,7 @@ public function testCanExecuteSimpleConfiguration(): void return new Configuration([], [$action1]); }); $context = $stateFlow - ->transition($this->createMock(State::class), []) + ->transition($this->createMock(State::class), new ArrayDelta([])) ->execute(); $this->assertInstanceOf(TransitionContext::class, $context); $this->assertCount(1, $context->getActionExecutions()); @@ -77,7 +79,7 @@ public function testCanExecuteWorkflowWithMultipleGatesAndActions(): void $createAuditLogAction = $this->createTestAction('CreateAuditLog'); // Configure StateFlow with multiple gates and actions - $stateFlow = new StateFlow(function (State $state, array $delta) use ( + $stateFlow = new StateFlow(function (State $state, Delta $delta) use ( $permissionGate, $validationGate, $approvalGate, @@ -94,7 +96,7 @@ public function testCanExecuteWorkflowWithMultipleGatesAndActions(): void // Execute the transition $context = $stateFlow - ->transition($initialState, ['status' => 'published', 'approved' => true]) + ->transition($initialState, new ArrayDelta(['status' => 'published', 'approved' => true])) ->execute(); // Verify execution @@ -155,7 +157,7 @@ public function testCanExecuteWorkflowWithActionReturningNewState(): void $stateFlow = new StateFlow(fn () => new Configuration([], [$publishAction])); $context = $stateFlow - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); $this->assertCount(1, $context->getActionExecutions()); diff --git a/tests/Integration/StateFlow/ComplexWorkflowTest.php b/tests/Integration/StateFlow/ComplexWorkflowTest.php index ab7c0ff..0b23f33 100644 --- a/tests/Integration/StateFlow/ComplexWorkflowTest.php +++ b/tests/Integration/StateFlow/ComplexWorkflowTest.php @@ -7,7 +7,9 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; +use BenRowe\StateFlow\Delta; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateContext; use BenRowe\StateFlow\Gate\GateResult; @@ -42,7 +44,7 @@ public function testConditionalBranchingBasedOnState(): void $sendPublishedNotificationAction = $this->createTrackingAction('SendPublishedNotification', $executionLog); // Configuration provider that returns different actions based on state - $configProvider = function (State $state, array $delta) use ( + $configProvider = function (State $state, Delta $delta) use ( $checkApprovalAction, $sendApprovalNotificationAction, $sendPublishedNotificationAction @@ -66,7 +68,7 @@ public function testConditionalBranchingBasedOnState(): void // Transition from draft to published - should execute approval workflow $draftState = $this->createTestState(['status' => 'draft', 'title' => 'My Document']); - $worker = $stateFlow->transition($draftState, ['status' => 'published']); + $worker = $stateFlow->transition($draftState, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); $this->assertTrue($context->isCompleted()); @@ -77,7 +79,7 @@ public function testConditionalBranchingBasedOnState(): void // Transition from published state - should execute published workflow $publishedState = $this->createTestState(['status' => 'published', 'title' => 'My Document']); - $worker = $stateFlow->transition($publishedState, ['title' => 'Updated Document']); + $worker = $stateFlow->transition($publishedState, new ArrayDelta(['title' => 'Updated Document'])); $context = $worker->execute(); $this->assertTrue($context->isCompleted()); @@ -125,7 +127,7 @@ public function testMultiStepApprovalWorkflow(): void // Execute the workflow - should pause at step 3 $draftState = $this->createTestState(['status' => 'draft', 'title' => 'Document']); - $worker = $stateFlow->transition($draftState, ['status' => 'pending_approval']); + $worker = $stateFlow->transition($draftState, new ArrayDelta(['status' => 'pending_approval'])); $context = $worker->execute(); // Verify workflow paused @@ -207,7 +209,7 @@ public function message(): string // First transition: draft -> published $state = $this->createTestState(['status' => 'draft', 'title' => 'Document']); - $worker = $stateFlow->transition($state, ['status' => 'published']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); // Should complete successfully @@ -221,7 +223,7 @@ public function message(): string // Second transition: same transition (draft -> published) $state2 = $this->createTestState(['status' => 'draft', 'title' => 'Another Document']); - $worker2 = $stateFlow->transition($state2, ['status' => 'published']); + $worker2 = $stateFlow->transition($state2, new ArrayDelta(['status' => 'published'])); $context2 = $worker2->execute(); // Should complete but skip actions due to idempotency diff --git a/tests/Integration/StateFlow/DeltaAccessTest.php b/tests/Integration/StateFlow/DeltaAccessTest.php index e4570db..5168e9a 100644 --- a/tests/Integration/StateFlow/DeltaAccessTest.php +++ b/tests/Integration/StateFlow/DeltaAccessTest.php @@ -7,6 +7,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateContext; @@ -70,7 +71,7 @@ public function message(): string $stateFlow = new StateFlow(fn () => new Configuration([$gate], [])); $context = $stateFlow - ->transition($initialState, $expectedDelta) + ->transition($initialState, new ArrayDelta($expectedDelta)) ->execute(); // Verify the gate received the delta @@ -112,7 +113,7 @@ public function execute(ActionContext $context): ActionResult $stateFlow = new StateFlow(fn () => new Configuration([], [$action])); $context = $stateFlow - ->transition($initialState, $expectedDelta) + ->transition($initialState, new ArrayDelta($expectedDelta)) ->execute(); // Verify the action received the delta diff --git a/tests/Integration/StateFlow/ErrorHandlingTest.php b/tests/Integration/StateFlow/ErrorHandlingTest.php index fe94a70..542cebc 100644 --- a/tests/Integration/StateFlow/ErrorHandlingTest.php +++ b/tests/Integration/StateFlow/ErrorHandlingTest.php @@ -7,6 +7,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Exceptions\InvalidConfigurationException; use BenRowe\StateFlow\Exceptions\InvalidGateResultException; @@ -107,7 +108,7 @@ public function testActionExceptionPropagatesWithoutEventDispatcher(): void $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Action failed'); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $worker->execute(); } @@ -126,7 +127,7 @@ public function testActionExceptionStopsWorkflow(): void $stateFlow = new StateFlow(fn () => $config); try { - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $worker->execute(); $this->fail('Expected exception to be thrown'); } catch (RuntimeException $e) { @@ -166,7 +167,7 @@ public function message(): ?string $this->expectException(InvalidGateResultException::class); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $worker->execute(); } diff --git a/tests/Integration/StateFlow/EventDispatchingTest.php b/tests/Integration/StateFlow/EventDispatchingTest.php index b6d4bb3..386a907 100644 --- a/tests/Integration/StateFlow/EventDispatchingTest.php +++ b/tests/Integration/StateFlow/EventDispatchingTest.php @@ -7,6 +7,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Events\ActionExecuted; use BenRowe\StateFlow\Events\ActionExecuting; @@ -53,7 +54,7 @@ public function testDispatchesTransitionStartingEvent(): void { $mockDispatcher = $this->createMock(EventDispatcher::class); $initialState = $this->createTestState(['status' => 'draft']); - $delta = ['status' => 'review']; + $delta = new ArrayDelta(['status' => 'review']); $mockDispatcher ->expects($this->once()) diff --git a/tests/Integration/StateFlow/ExecutionControlTest.php b/tests/Integration/StateFlow/ExecutionControlTest.php index 306553a..2bdbace 100644 --- a/tests/Integration/StateFlow/ExecutionControlTest.php +++ b/tests/Integration/StateFlow/ExecutionControlTest.php @@ -8,6 +8,7 @@ use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; use BenRowe\StateFlow\Action\ExecutionState; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\StateFlow; use BenRowe\StateFlow\Tests\Utils\ExecutionLogger; @@ -46,7 +47,7 @@ public function testActionReturnsPauseStopsExecution(): void $stateFlow = new StateFlow(fn () => new Configuration([], [$action1, $action2, $action3])); $context = $stateFlow - ->transition($initialState, ['status' => 'approved']) + ->transition($initialState, new ArrayDelta(['status' => 'approved'])) ->execute(); // Only actions 1 and 2 should execute, action 3 should NOT @@ -84,7 +85,7 @@ public function testActionReturnsStopHaltsExecution(): void $stateFlow = new StateFlow(fn () => new Configuration([], [$action1, $action2, $action3])); $context = $stateFlow - ->transition($initialState, ['status' => 'failed']) + ->transition($initialState, new ArrayDelta(['status' => 'failed'])) ->execute(); // Only actions 1 and 2 should execute, action 3 should NOT @@ -204,7 +205,7 @@ public function execute(ActionContext $context): ActionResult $stateFlow = new StateFlow(fn () => new Configuration([], [$action1, $action2, $action3])); $context = $stateFlow - ->transition($initialState, $delta) + ->transition($initialState, new ArrayDelta($delta)) ->execute(); // Verify action 1 received initial state diff --git a/tests/Integration/StateFlow/LockingTest.php b/tests/Integration/StateFlow/LockingTest.php index 6571917..d1593be 100644 --- a/tests/Integration/StateFlow/LockingTest.php +++ b/tests/Integration/StateFlow/LockingTest.php @@ -7,6 +7,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Events\Event; use BenRowe\StateFlow\Events\EventDispatcher; @@ -89,7 +90,7 @@ public function testAcquireLockBeforeTransition(): void $state = $this->createTestState(['id' => '123', 'status' => 'pending']); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); // Verify LockAcquired event was dispatched @@ -194,7 +195,7 @@ public function execute(ActionContext $context): ActionResult $this->expectExceptionMessage('Failed to acquire lock'); try { - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $worker->execute(); } finally { // Verify LockFailed event was dispatched @@ -296,7 +297,7 @@ public function execute(ActionContext $context): ActionResult $state = $this->createTestState(['id' => '123', 'status' => 'pending']); // Execute - should NOT throw exception - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); // Verify LockFailed event was dispatched @@ -403,7 +404,7 @@ public function execute(ActionContext $context): ActionResult $state = $this->createTestState(['id' => '123', 'status' => 'pending']); $startTime = microtime(true); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); $endTime = microtime(true); $duration = $endTime - $startTime; @@ -517,7 +518,7 @@ public function execute(ActionContext $context): ActionResult $startTime = microtime(true); try { - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $worker->execute(); } finally { $endTime = microtime(true); @@ -594,7 +595,7 @@ public function testReleaseLockAfterCompletion(): void $state = $this->createTestState(['id' => '123', 'status' => 'pending']); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); // Verify lock was acquired @@ -687,7 +688,7 @@ public function execute(ActionContext $context): ActionResult $this->expectExceptionMessage('Action failed'); try { - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $worker->execute(); } finally { // Verify LockReleased event was dispatched even though exception was thrown @@ -769,7 +770,7 @@ public function execute(ActionContext $context): ActionResult $state = $this->createTestState(['id' => '123', 'status' => 'pending']); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); // Verify lock is still held @@ -859,7 +860,7 @@ public function execute(ActionContext $context): ActionResult $state = $this->createTestState(['id' => '123', 'status' => 'pending']); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); // Verify transition is stopped @@ -971,7 +972,7 @@ public function execute(ActionContext $context): ActionResult $state = $this->createTestState(['id' => '123', 'status' => 'pending']); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); // Verify transition completed @@ -1079,7 +1080,7 @@ public function execute(ActionContext $context): ActionResult $state = $this->createTestState(['id' => '123', 'status' => 'pending']); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); // Verify transition completed despite failed renewal @@ -1184,7 +1185,7 @@ public function execute(ActionContext $context): ActionResult $this->expectExceptionMessage('Lock was lost'); try { - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $worker->execute(); } finally { // Verify first action executed diff --git a/tests/Integration/StateFlow/SerializationTest.php b/tests/Integration/StateFlow/SerializationTest.php index 9e436b5..af19de8 100644 --- a/tests/Integration/StateFlow/SerializationTest.php +++ b/tests/Integration/StateFlow/SerializationTest.php @@ -8,6 +8,7 @@ use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; use BenRowe\StateFlow\ActionFactory; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateContext; @@ -65,7 +66,7 @@ public function testSerializeAndResumePausedWorkflow(): void // ===== PART 1: Execute workflow until pause ===== $stateFlow1 = new StateFlow(fn () => $config); - $worker1 = $stateFlow1->transition($initialState, ['status' => 'pending']); + $worker1 = $stateFlow1->transition($initialState, new ArrayDelta(['status' => 'pending'])); $pausedContext = $worker1->execute(); // Verify workflow paused @@ -142,7 +143,7 @@ public function testSerializeWorkflowWithGates(): void // Execute workflow $stateFlow = new StateFlow(fn () => $config); - $context = $stateFlow->transition($initialState, ['status' => 'published'])->execute(); + $context = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'published']))->execute(); // Serialize and unserialize $serialized = $context->serialize(); @@ -182,7 +183,7 @@ public function testSerializeStoppedWorkflow(): void // Execute workflow $stateFlow = new StateFlow(fn () => $config); - $context = $stateFlow->transition($initialState, ['status' => 'completed'])->execute(); + $context = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'completed']))->execute(); // Verify workflow stopped $this->assertTrue($context->isStopped()); @@ -225,7 +226,7 @@ public function testSerializeWorkflowWithStateChanges(): void // Execute workflow $stateFlow = new StateFlow(fn () => $config); - $context = $stateFlow->transition($initialState, ['status' => 'review'])->execute(); + $context = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'review']))->execute(); // Verify state was updated $this->assertEquals( diff --git a/tests/Integration/StateFlow/StepByStepExecutionTest.php b/tests/Integration/StateFlow/StepByStepExecutionTest.php index 0b5dc1b..8893357 100644 --- a/tests/Integration/StateFlow/StepByStepExecutionTest.php +++ b/tests/Integration/StateFlow/StepByStepExecutionTest.php @@ -5,6 +5,7 @@ namespace BenRowe\StateFlow\Tests\Integration\StateFlow; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Gate\GateResult; use BenRowe\StateFlow\StateFlow; @@ -50,7 +51,7 @@ public function testRunGatesOnly(): void [$action1, $action2] )); - $worker = $stateFlow->transition($initialState, ['status' => 'published']); + $worker = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'published'])); // Run gates only $result = $worker->runGates(); @@ -98,7 +99,7 @@ public function testRunGatesOnlyWithDenial(): void [$action1] )); - $worker = $stateFlow->transition($initialState, ['status' => 'published']); + $worker = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'published'])); $result = $worker->runGates(); // Verify the final result is DENY @@ -134,7 +135,7 @@ public function testRunGatesThenActionsSeparately(): void [$action1, $action2, $action3] )); - $worker = $stateFlow->transition($initialState, ['status' => 'published']); + $worker = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'published'])); // Step 1: Run gates $gateResult = $worker->runGates(); @@ -177,7 +178,7 @@ public function testRunNextActionIncrementally(): void [$action1, $action2, $action3] )); - $worker = $stateFlow->transition($initialState, ['status' => 'published']); + $worker = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'published'])); // First call: execute action 1 $context1 = $worker->runNextAction(); @@ -217,7 +218,7 @@ public function testRunNextActionWithActionGates(): void $stateFlow = new StateFlow(fn () => new Configuration([], [$action1, $action2, $action3])); - $worker = $stateFlow->transition($initialState, ['status' => 'published']); + $worker = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'published'])); // Action 1: gate allows, should execute $context1 = $worker->runNextAction(); @@ -255,7 +256,7 @@ public function testExecuteIsShorthandForGatesAndActions(): void [$action1, $action2] )); - $worker1 = $stateFlow1->transition($initialState, ['status' => 'published']); + $worker1 = $stateFlow1->transition($initialState, new ArrayDelta(['status' => 'published'])); $gateResult = $worker1->runGates(); $this->assertSame(GateResult::ALLOW, $gateResult); @@ -268,7 +269,7 @@ public function testExecuteIsShorthandForGatesAndActions(): void )); $executeContext = $stateFlow2 - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); // Verify both approaches produce the same results @@ -309,7 +310,7 @@ public function testExecuteStopsAtGateDenial(): void )); $context = $stateFlow - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); // Verify gates were evaluated @@ -340,7 +341,7 @@ public function testExecutePauseThenContinueWithRunNextAction(): void $stateFlow = new StateFlow(fn () => new Configuration([], [$action1, $action2, $action3])); - $worker = $stateFlow->transition($initialState, ['status' => 'published']); + $worker = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'published'])); // Execute - should run actions 1-2 and pause $context1 = $worker->execute(); @@ -378,7 +379,7 @@ public function testExecuteStopThenRunNextActionDoesNothing(): void $stateFlow = new StateFlow(fn () => new Configuration([], [$action1, $action2, $action3])); - $worker = $stateFlow->transition($initialState, ['status' => 'published']); + $worker = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'published'])); // Execute - should run actions 1-2 and stop $context1 = $worker->execute(); diff --git a/tests/Integration/StateFlow/TransitionGateTest.php b/tests/Integration/StateFlow/TransitionGateTest.php index c096017..465c57d 100644 --- a/tests/Integration/StateFlow/TransitionGateTest.php +++ b/tests/Integration/StateFlow/TransitionGateTest.php @@ -4,6 +4,7 @@ namespace BenRowe\StateFlow\Tests\Integration\StateFlow; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Gate\GateResult; use BenRowe\StateFlow\StateFlow; @@ -50,7 +51,7 @@ public function testAllGatesAllowTransition(): void )); $context = $stateFlow - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); // Verify all 3 gates were evaluated @@ -108,7 +109,7 @@ public function testFirstGateDeniesTransitionWithShortCircuit(): void )); $context = $stateFlow - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); // Verify ONLY first gate was evaluated (short-circuit) @@ -152,7 +153,7 @@ public function testGateWithSkipIdempotentResult(): void )); $context = $stateFlow - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); // Verify the gate was evaluated @@ -198,7 +199,7 @@ public function testMiddleGateDeniesTransitionWithShortCircuit(): void )); $context = $stateFlow - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); // Verify gates 1 and 2 were evaluated, but NOT gate 3 (short-circuit) @@ -245,7 +246,7 @@ public function testGateDenialPreventsActionsFromExecuting(): void )); $context = $stateFlow - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); // Verify gates were evaluated @@ -291,7 +292,7 @@ public function testLastGateDeniesTransition(): void )); $context = $stateFlow - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); // Verify all 3 gates were evaluated diff --git a/tests/Integration/StateFlow/WorkflowResumeTest.php b/tests/Integration/StateFlow/WorkflowResumeTest.php index aca64f4..4f4b099 100644 --- a/tests/Integration/StateFlow/WorkflowResumeTest.php +++ b/tests/Integration/StateFlow/WorkflowResumeTest.php @@ -5,6 +5,7 @@ namespace BenRowe\StateFlow\Tests\Integration\StateFlow; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\StateFlow; use BenRowe\StateFlow\Tests\Utils\ExecutionLogger; @@ -53,7 +54,7 @@ public function testResumePausedWorkflow(): void // Step 1: Initial execution - should pause after action 2 $stateFlow1 = new StateFlow(fn () => $configuration); - $worker1 = $stateFlow1->transition($initialState, ['status' => 'pending']); + $worker1 = $stateFlow1->transition($initialState, new ArrayDelta(['status' => 'pending'])); $pausedContext = $worker1->execute(); // Verify the workflow paused @@ -114,7 +115,7 @@ public function testResumePausedWorkflowWithStateChanges(): void // Initial execution $stateFlow1 = new StateFlow(fn () => $configuration); $pausedContext = $stateFlow1 - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); // Verify state after pause @@ -154,7 +155,7 @@ public function testResumeCompletedWorkflowDoesNothing(): void // Execute to completion $stateFlow1 = new StateFlow(fn () => $configuration); $completedContext = $stateFlow1 - ->transition($initialState, ['status' => 'published']) + ->transition($initialState, new ArrayDelta(['status' => 'published'])) ->execute(); $this->assertTrue($completedContext->isCompleted()); @@ -197,7 +198,7 @@ public function testResumeStoppedWorkflowDoesNothing(): void // Execute until stopped $stateFlow1 = new StateFlow(fn () => $configuration); $stoppedContext = $stateFlow1 - ->transition($initialState, ['status' => 'failed']) + ->transition($initialState, new ArrayDelta(['status' => 'failed'])) ->execute(); $this->assertTrue($stoppedContext->isStopped()); @@ -238,7 +239,7 @@ public function testResumedWorkflowUsesOriginalConfiguration(): void // Step 1: Initial execution. Should use config V1 and pause after ActionA $stateFlow1 = new StateFlow($configProvider); - $pausedContext = $stateFlow1->transition($initialState, [])->execute(); + $pausedContext = $stateFlow1->transition($initialState, new ArrayDelta([]))->execute(); $this->assertTrue($pausedContext->isPaused()); $this->assertContains('Action:ActionA', $this->logger->log); From a6b5f1ae26acc0b2026dd4fc447d80f6427fbd82 Mon Sep 17 00:00:00 2001 From: Ben Rowe Date: Thu, 11 Dec 2025 19:06:32 +1100 Subject: [PATCH 06/10] Update baseline for phpstan --- phpstan-baseline.neon | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e46c334..92fe212 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -84,12 +84,6 @@ parameters: count: 1 path: src/TransitionContext.php - - - message: '#^Parameter \#2 \$desiredDelta of class BenRowe\\StateFlow\\TransitionContext constructor expects array\, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/TransitionContext.php - - message: '#^Property BenRowe\\StateFlow\\TransitionContext\:\:\$skippedDueToLock \(bool\) does not accept mixed\.$#' identifier: assign.propertyType @@ -127,7 +121,7 @@ parameters: path: tests/Integration/StateFlow/DeltaAccessTest.php - - message: '#^Method BenRowe\\StateFlow\\Gate\\Gate@anonymous/tests/Integration/StateFlow/ErrorHandlingTest\.php\:151\:\:message\(\) never returns null so it can be removed from the return type\.$#' + message: '#^Method BenRowe\\StateFlow\\Gate\\Gate@anonymous/tests/Integration/StateFlow/ErrorHandlingTest\.php\:152\:\:message\(\) never returns null so it can be removed from the return type\.$#' identifier: return.unusedType count: 1 path: tests/Integration/StateFlow/ErrorHandlingTest.php @@ -157,19 +151,19 @@ parameters: path: tests/Integration/StateFlow/ExecutionControlTest.php - - message: '#^Property BenRowe\\StateFlow\\Action\\Action@anonymous/tests/Integration/StateFlow/ExecutionControlTest\.php\:122\:\:\$stateCaptures is never read, only written\.$#' + message: '#^Property BenRowe\\StateFlow\\Action\\Action@anonymous/tests/Integration/StateFlow/ExecutionControlTest\.php\:123\:\:\$stateCaptures is never read, only written\.$#' identifier: property.onlyWritten count: 1 path: tests/Integration/StateFlow/ExecutionControlTest.php - - message: '#^Property BenRowe\\StateFlow\\Action\\Action@anonymous/tests/Integration/StateFlow/ExecutionControlTest\.php\:150\:\:\$stateCaptures is never read, only written\.$#' + message: '#^Property BenRowe\\StateFlow\\Action\\Action@anonymous/tests/Integration/StateFlow/ExecutionControlTest\.php\:151\:\:\$stateCaptures is never read, only written\.$#' identifier: property.onlyWritten count: 1 path: tests/Integration/StateFlow/ExecutionControlTest.php - - message: '#^Property BenRowe\\StateFlow\\Action\\Action@anonymous/tests/Integration/StateFlow/ExecutionControlTest\.php\:178\:\:\$stateCaptures is never read, only written\.$#' + message: '#^Property BenRowe\\StateFlow\\Action\\Action@anonymous/tests/Integration/StateFlow/ExecutionControlTest\.php\:179\:\:\$stateCaptures is never read, only written\.$#' identifier: property.onlyWritten count: 1 path: tests/Integration/StateFlow/ExecutionControlTest.php From cd8b8b1eb1b612cadb0cad225735d6c04452d005 Mon Sep 17 00:00:00 2001 From: Ben Rowe Date: Thu, 11 Dec 2025 19:06:57 +1100 Subject: [PATCH 07/10] Update src --- src/ArrayDelta.php | 2 +- src/TransitionContext.php | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ArrayDelta.php b/src/ArrayDelta.php index 9262c8a..c737782 100644 --- a/src/ArrayDelta.php +++ b/src/ArrayDelta.php @@ -27,7 +27,7 @@ public function has(string $key): bool public function get(string $key, mixed $default = null): mixed { - return $this->changes[$key] ?? $default; + return array_key_exists($key, $this->changes) ? $this->changes[$key] : $default; } public function asArray(): array diff --git a/src/TransitionContext.php b/src/TransitionContext.php index 0a06dd9..07a2fef 100644 --- a/src/TransitionContext.php +++ b/src/TransitionContext.php @@ -286,7 +286,9 @@ public static function unserialize( $configuration = self::restoreConfiguration($decoded, $actionFactory, $gateFactory); // Create new context - $desiredDelta = new ArrayDelta($decoded['desiredDelta'] ?? []); + /** @var array $deltaData */ + $deltaData = $decoded['desiredDelta'] ?? []; + $desiredDelta = new ArrayDelta($deltaData); $context = new self($initialState, $desiredDelta, $configuration); // Restore current state From dd0e95476f5426f2100fbc0370fe573e2c725057 Mon Sep 17 00:00:00 2001 From: Ben Rowe Date: Thu, 11 Dec 2025 20:53:25 +1100 Subject: [PATCH 08/10] Update integration tests to use delta interface --- .../StateFlow/ComplexWorkflowTest.php | 4 ++-- .../Integration/StateFlow/DeltaAccessTest.php | 11 ++++++---- .../StateFlow/EventDispatchingTest.php | 22 +++++++++---------- .../StateFlow/ExecutionControlTest.php | 2 +- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/tests/Integration/StateFlow/ComplexWorkflowTest.php b/tests/Integration/StateFlow/ComplexWorkflowTest.php index 0b23f33..431cd81 100644 --- a/tests/Integration/StateFlow/ComplexWorkflowTest.php +++ b/tests/Integration/StateFlow/ComplexWorkflowTest.php @@ -52,7 +52,7 @@ public function testConditionalBranchingBasedOnState(): void $stateData = $state->toArray(); // Draft -> Published: approval workflow - if ($stateData['status'] === 'draft' && isset($delta['status']) && $delta['status'] === 'published') { + if ($stateData['status'] === 'draft' && $delta->has('status') && $delta->get('status') === 'published') { return new Configuration([], [$checkApprovalAction, $sendApprovalNotificationAction]); } @@ -182,7 +182,7 @@ public function evaluate(GateContext $context): GateResult // Create a unique key for this transition $fromStatus = is_string($state['status'] ?? null) ? $state['status'] : 'unknown'; - $toStatus = is_string($delta['status'] ?? null) ? $delta['status'] : 'unknown'; + $toStatus = is_string($delta->get('status')) ? $delta->get('status') : 'unknown'; $key = sprintf('%s->%s', $fromStatus, $toStatus); // Check if we've seen this transition before diff --git a/tests/Integration/StateFlow/DeltaAccessTest.php b/tests/Integration/StateFlow/DeltaAccessTest.php index 5168e9a..49b47ee 100644 --- a/tests/Integration/StateFlow/DeltaAccessTest.php +++ b/tests/Integration/StateFlow/DeltaAccessTest.php @@ -9,6 +9,7 @@ use BenRowe\StateFlow\Action\ActionResult; use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; +use BenRowe\StateFlow\Delta; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateContext; use BenRowe\StateFlow\Gate\GateResult; @@ -75,8 +76,9 @@ public function message(): string ->execute(); // Verify the gate received the delta - $this->assertSame($expectedDelta, $deltaCapture, 'Gate should receive the delta'); - $this->assertSame($expectedDelta, $context->getDesiredDelta(), 'Context should store the delta'); + $this->assertInstanceOf(Delta::class, $deltaCapture); + $this->assertSame($expectedDelta, $deltaCapture->asArray(), 'Gate should receive the delta'); + $this->assertSame($expectedDelta, $context->getDesiredDelta()->asArray(), 'Context should store the delta'); } /** @@ -117,8 +119,9 @@ public function execute(ActionContext $context): ActionResult ->execute(); // Verify the action received the delta - $this->assertSame($expectedDelta, $deltaCapture, 'Action should receive the delta'); - $this->assertSame($expectedDelta, $context->getDesiredDelta(), 'Context should store the delta'); + $this->assertInstanceOf(Delta::class, $deltaCapture); + $this->assertSame($expectedDelta, $deltaCapture->asArray(), 'Action should receive the delta'); + $this->assertSame($expectedDelta, $context->getDesiredDelta()->asArray(), 'Context should store the delta'); } protected function getLogger(): ExecutionLogger diff --git a/tests/Integration/StateFlow/EventDispatchingTest.php b/tests/Integration/StateFlow/EventDispatchingTest.php index 386a907..0635e42 100644 --- a/tests/Integration/StateFlow/EventDispatchingTest.php +++ b/tests/Integration/StateFlow/EventDispatchingTest.php @@ -82,7 +82,7 @@ public function testDispatchesTransitionCompletedEvent(): void $dispatchedEvents = []; $mockDispatcher = $this->createMock(EventDispatcher::class); $initialState = $this->createTestState(['status' => 'draft']); - $delta = ['status' => 'published']; + $delta = new ArrayDelta(['status' => 'published']); $finalState = $this->createTestState(['status' => 'published']); $mockDispatcher @@ -124,7 +124,7 @@ public function testTransitionCompletedEventDoesNotFireOnPause(): void $dispatchedEvents = []; $mockDispatcher = $this->createMock(EventDispatcher::class); $initialState = $this->createTestState(['status' => 'draft']); - $delta = ['status' => 'paused']; + $delta = new ArrayDelta(['status' => 'paused']); $mockDispatcher ->expects($this->any()) @@ -164,7 +164,7 @@ public function testTransitionCompletedEventDoesNotFireOnStop(): void $dispatchedEvents = []; $mockDispatcher = $this->createMock(EventDispatcher::class); $initialState = $this->createTestState(['status' => 'draft']); - $delta = ['status' => 'stopped']; + $delta = new ArrayDelta(['status' => 'stopped']); $mockDispatcher ->expects($this->any()) @@ -204,7 +204,7 @@ public function testDispatchesTransitionPausedEvent(): void $dispatchedEvents = []; $mockDispatcher = $this->createMock(EventDispatcher::class); $initialState = $this->createTestState(['status' => 'draft']); - $delta = ['status' => 'review']; + $delta = new ArrayDelta(['status' => 'review']); $metadata = ['reason' => 'waiting for dependency']; $mockDispatcher @@ -246,7 +246,7 @@ public function testDispatchesTransitionStoppedEvent(): void $dispatchedEvents = []; $mockDispatcher = $this->createMock(EventDispatcher::class); $initialState = $this->createTestState(['status' => 'draft']); - $delta = ['status' => 'stopped']; + $delta = new ArrayDelta(['status' => 'stopped']); $metadata = ['reason' => 'user cancelled']; $mockDispatcher @@ -285,7 +285,7 @@ public function testDispatchesTransitionFailedEvent(): void $dispatchedEvents = []; $mockDispatcher = $this->createMock(EventDispatcher::class); $initialState = $this->createTestState(['status' => 'draft']); - $delta = ['status' => 'failed']; + $delta = new ArrayDelta(['status' => 'failed']); $exception = new \RuntimeException('Test exception'); $mockDispatcher @@ -334,7 +334,7 @@ public function testDispatchesGateEventsForTransitionGates(): void $dispatchedEvents = []; $mockDispatcher = $this->createMock(EventDispatcher::class); $initialState = $this->createTestState(['status' => 'draft']); - $delta = ['status' => 'published']; + $delta = new ArrayDelta(['status' => 'published']); $mockDispatcher ->expects($this->any()) @@ -383,7 +383,7 @@ public function testDispatchesGateEventsForActionGates(): void $dispatchedEvents = []; $mockDispatcher = $this->createMock(EventDispatcher::class); $initialState = $this->createTestState(['status' => 'draft']); - $delta = ['status' => 'published']; + $delta = new ArrayDelta(['status' => 'published']); $mockDispatcher ->expects($this->any()) @@ -426,7 +426,7 @@ public function testDispatchesActionExecutingAndExecutedEvents(): void $dispatchedEvents = []; $mockDispatcher = $this->createMock(EventDispatcher::class); $initialState = $this->createTestState(['status' => 'draft']); - $delta = ['status' => 'published']; + $delta = new ArrayDelta(['status' => 'published']); $mockDispatcher ->expects($this->any()) @@ -469,7 +469,7 @@ public function testDispatchesActionSkippedEventWhenGateDenies(): void $dispatchedEvents = []; $mockDispatcher = $this->createMock(EventDispatcher::class); $initialState = $this->createTestState(['status' => 'draft']); - $delta = ['status' => 'published']; + $delta = new ArrayDelta(['status' => 'published']); $mockDispatcher ->expects($this->any()) @@ -504,7 +504,7 @@ public function testDispatchesActionSkippedEventsWhenTransitionGateDenies(): voi $dispatchedEvents = []; $mockDispatcher = $this->createMock(EventDispatcher::class); $initialState = $this->createTestState(['status' => 'draft']); - $delta = ['status' => 'published']; + $delta = new ArrayDelta(['status' => 'published']); $mockDispatcher ->expects($this->any()) diff --git a/tests/Integration/StateFlow/ExecutionControlTest.php b/tests/Integration/StateFlow/ExecutionControlTest.php index 2bdbace..aa82e48 100644 --- a/tests/Integration/StateFlow/ExecutionControlTest.php +++ b/tests/Integration/StateFlow/ExecutionControlTest.php @@ -140,7 +140,7 @@ public function execute(ActionContext $context): ActionResult $this->stateCaptures['action1_received'] = $context->currentState->toArray(); // Use state.with(delta) to merge the delta into current state - $newState = $context->currentState->with($context->desiredDelta); + $newState = $context->currentState->with($context->desiredDelta->asArray()); $this->stateCaptures['action1_returned'] = $newState->toArray(); return ActionResult::continue($newState); From 44e681cebdfb191d5255c9876d037773056ca83a Mon Sep 17 00:00:00 2001 From: Ben Rowe Date: Thu, 11 Dec 2025 20:53:45 +1100 Subject: [PATCH 09/10] Update unit tests to use delta interface --- .../CallableConfigurationProviderTest.php | 24 +++--- tests/Unit/Events/ActionExecutedTest.php | 3 +- tests/Unit/Events/ActionExecutingTest.php | 3 +- tests/Unit/Events/GateEvaluatedTest.php | 3 +- tests/Unit/Events/GateEvaluatingTest.php | 3 +- tests/Unit/Events/TransitionStartingTest.php | 3 +- tests/Unit/StateFlowTest.php | 5 +- tests/Unit/StateWorkerTest.php | 31 ++++---- .../TransitionContextSerializationTest.php | 59 +++++++-------- tests/Unit/TransitionContextTest.php | 73 ++++++++++--------- 10 files changed, 109 insertions(+), 98 deletions(-) diff --git a/tests/Unit/Configuration/CallableConfigurationProviderTest.php b/tests/Unit/Configuration/CallableConfigurationProviderTest.php index eb4598d..2db67e3 100644 --- a/tests/Unit/Configuration/CallableConfigurationProviderTest.php +++ b/tests/Unit/Configuration/CallableConfigurationProviderTest.php @@ -5,8 +5,10 @@ namespace BenRowe\StateFlow\Tests\Unit\Configuration; use BenRowe\StateFlow\Action\Action; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\CallableConfigurationProvider; use BenRowe\StateFlow\Configuration\Configuration; +use BenRowe\StateFlow\Delta; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\State; use PHPUnit\Framework\TestCase; @@ -16,11 +18,11 @@ class CallableConfigurationProviderTest extends TestCase public function testProvideCallsCallableWithStateAndDelta(): void { $state = $this->createMock(State::class); - $delta = ['status' => 'active']; + $delta = new ArrayDelta(['status' => 'active']); $expectedConfig = new Configuration([], []); $callableInvoked = false; - $callable = function (State $configState, array $configDelta) use ($state, $delta, $expectedConfig, &$callableInvoked) { + $callable = function (State $configState, Delta $configDelta) use ($state, $delta, $expectedConfig, &$callableInvoked) { $this->assertSame($state, $configState); $this->assertSame($delta, $configDelta); $callableInvoked = true; @@ -41,10 +43,10 @@ public function testProvideReturnsConfigurationFromCallable(): void $gate = $this->createMock(Gate::class); $action = $this->createMock(Action::class); - $callable = fn (State $s, array $d) => new Configuration([$gate], [$action]); + $callable = fn (State $s, Delta $d) => new Configuration([$gate], [$action]); $provider = new CallableConfigurationProvider($callable); - $config = $provider->provide($state, ['foo' => 'bar']); + $config = $provider->provide($state, new ArrayDelta(['foo' => 'bar'])); $this->assertCount(1, $config->getTransitionGates()); $this->assertCount(1, $config->getActions()); @@ -63,7 +65,7 @@ public function testProvideWithDifferentStatesReturnsDifferentConfigurations(): $pendingGate = $this->createMock(Gate::class); $activeGate = $this->createMock(Gate::class); - $callable = function (State $state, array $delta) use ($pendingGate, $activeGate) { + $callable = function (State $state, Delta $delta) use ($pendingGate, $activeGate) { $stateData = $state->toArray(); if ($stateData['status'] === 'pending') { return new Configuration([$pendingGate], []); @@ -74,8 +76,8 @@ public function testProvideWithDifferentStatesReturnsDifferentConfigurations(): $provider = new CallableConfigurationProvider($callable); - $config1 = $provider->provide($state1, []); - $config2 = $provider->provide($state2, []); + $config1 = $provider->provide($state1, new ArrayDelta([])); + $config2 = $provider->provide($state2, new ArrayDelta([])); $this->assertSame($pendingGate, $config1->getTransitionGates()[0]); $this->assertSame($activeGate, $config2->getTransitionGates()[0]); @@ -87,8 +89,8 @@ public function testProvideWithDifferentDeltasReturnsDifferentConfigurations(): $action1 = $this->createMock(Action::class); $action2 = $this->createMock(Action::class); - $callable = function (State $state, array $delta) use ($action1, $action2) { - if (isset($delta['priority']) && $delta['priority'] === 'high') { + $callable = function (State $state, Delta $delta) use ($action1, $action2) { + if ($delta->has('priority') && $delta->get('priority') === 'high') { return new Configuration([], [$action1]); } @@ -97,8 +99,8 @@ public function testProvideWithDifferentDeltasReturnsDifferentConfigurations(): $provider = new CallableConfigurationProvider($callable); - $config1 = $provider->provide($state, ['priority' => 'high']); - $config2 = $provider->provide($state, ['priority' => 'low']); + $config1 = $provider->provide($state, new ArrayDelta(['priority' => 'high'])); + $config2 = $provider->provide($state, new ArrayDelta(['priority' => 'low'])); $this->assertSame($action1, $config1->getActions()[0]); $this->assertSame($action2, $config2->getActions()[0]); diff --git a/tests/Unit/Events/ActionExecutedTest.php b/tests/Unit/Events/ActionExecutedTest.php index 4e9b5b5..c732ba5 100644 --- a/tests/Unit/Events/ActionExecutedTest.php +++ b/tests/Unit/Events/ActionExecutedTest.php @@ -7,6 +7,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Events\ActionExecuted; use BenRowe\StateFlow\State; use BenRowe\StateFlow\TransitionContext; @@ -19,7 +20,7 @@ public function testGetters(): void $action = $this->createMock(Action::class); $context = new ActionContext( $this->createMock(State::class), - ['foo' => 'bar'], + new ArrayDelta(['foo' => 'bar']), $this->createMock(TransitionContext::class) ); $result = ActionResult::continue(); diff --git a/tests/Unit/Events/ActionExecutingTest.php b/tests/Unit/Events/ActionExecutingTest.php index ea4e9ce..1989402 100644 --- a/tests/Unit/Events/ActionExecutingTest.php +++ b/tests/Unit/Events/ActionExecutingTest.php @@ -6,6 +6,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionContext; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Events\ActionExecuting; use BenRowe\StateFlow\State; use BenRowe\StateFlow\TransitionContext; @@ -18,7 +19,7 @@ public function testGetters(): void $action = $this->createMock(Action::class); $context = new ActionContext( $this->createMock(State::class), - ['foo' => 'bar'], + new ArrayDelta(['foo' => 'bar']), $this->createMock(TransitionContext::class) ); diff --git a/tests/Unit/Events/GateEvaluatedTest.php b/tests/Unit/Events/GateEvaluatedTest.php index 09fc25a..44c300c 100644 --- a/tests/Unit/Events/GateEvaluatedTest.php +++ b/tests/Unit/Events/GateEvaluatedTest.php @@ -4,6 +4,7 @@ namespace BenRowe\StateFlow\Tests\Unit\Events; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Events\GateEvaluated; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateContext; @@ -18,7 +19,7 @@ public function testGetters(): void $gate = $this->createMock(Gate::class); $context = new GateContext( $this->createMock(State::class), - ['foo' => 'bar'] + new ArrayDelta(['foo' => 'bar']) ); $result = GateResult::ALLOW; $isActionGate = true; diff --git a/tests/Unit/Events/GateEvaluatingTest.php b/tests/Unit/Events/GateEvaluatingTest.php index 1bc8f4a..105a407 100644 --- a/tests/Unit/Events/GateEvaluatingTest.php +++ b/tests/Unit/Events/GateEvaluatingTest.php @@ -4,6 +4,7 @@ namespace BenRowe\StateFlow\Tests\Unit\Events; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Events\GateEvaluating; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateContext; @@ -17,7 +18,7 @@ public function testGetters(): void $gate = $this->createMock(Gate::class); $context = new GateContext( $this->createMock(State::class), - ['foo' => 'bar'] + new ArrayDelta(['foo' => 'bar']) ); $isActionGate = false; diff --git a/tests/Unit/Events/TransitionStartingTest.php b/tests/Unit/Events/TransitionStartingTest.php index 0a416f8..d3d876b 100644 --- a/tests/Unit/Events/TransitionStartingTest.php +++ b/tests/Unit/Events/TransitionStartingTest.php @@ -4,6 +4,7 @@ namespace BenRowe\StateFlow\Tests\Unit\Events; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Events\TransitionStarting; use BenRowe\StateFlow\State; use PHPUnit\Framework\TestCase; @@ -13,7 +14,7 @@ class TransitionStartingTest extends TestCase public function testGetters(): void { $currentState = $this->createMock(State::class); - $desiredDelta = ['status' => 'active', 'priority' => 'high']; + $desiredDelta = new ArrayDelta(['status' => 'active', 'priority' => 'high']); $event = new TransitionStarting($currentState, $desiredDelta); diff --git a/tests/Unit/StateFlowTest.php b/tests/Unit/StateFlowTest.php index 44c040b..16dd8d6 100644 --- a/tests/Unit/StateFlowTest.php +++ b/tests/Unit/StateFlowTest.php @@ -4,6 +4,7 @@ namespace BenRowe\StateFlow\Tests\Unit; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\State; use BenRowe\StateFlow\StateFlow; @@ -34,7 +35,7 @@ public function testItCanBeInitialised(): void $this->assertInstanceOf(StateFlow::class, $stateFlow); $this->assertInstanceOf( StateWorker::class, - $stateFlow->transition($this->createMock(State::class), []) + $stateFlow->transition($this->createMock(State::class), new ArrayDelta([])) ); } @@ -43,7 +44,7 @@ public function testItCanBeInitialisedWithExistingContext(): void $stateFlow = new StateFlow(fn () => new Configuration([], [])); $state = $this->createTestState(['foo' => 'bar']); $config = new Configuration([], [$this->createTestAction('myAction')]); - $context = new TransitionContext($state, [], $config); + $context = new TransitionContext($state, new ArrayDelta([]), $config); $worker = $stateFlow->fromContext($context); $this->assertInstanceOf(StateWorker::class, $worker); diff --git a/tests/Unit/StateWorkerTest.php b/tests/Unit/StateWorkerTest.php index 78355d3..cbc3ba2 100644 --- a/tests/Unit/StateWorkerTest.php +++ b/tests/Unit/StateWorkerTest.php @@ -7,6 +7,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionContext; use BenRowe\StateFlow\Action\ActionResult; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Events\ActionExecuted; use BenRowe\StateFlow\Events\ActionExecuting; @@ -72,7 +73,7 @@ public function testSkipIdempotentMarksWorkflowAsCompletedAndDispatchesEvent(): // Create state and context $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); // Create mock event dispatcher to verify TransitionCompleted is dispatched $completedEventDispatched = false; @@ -136,7 +137,7 @@ public function testDenyDoesNotMarkWorkflowAsCompleted(): void // Create state and context $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); // Create mock event dispatcher to verify TransitionCompleted is NOT dispatched $mockDispatcher = $this->createMock(EventDispatcher::class); @@ -197,7 +198,7 @@ public function testAllowMarksWorkflowAsCompletedAfterActions(): void // Create state and context $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); // Create mock event dispatcher to verify TransitionCompleted is dispatched $completedEventDispatched = false; @@ -234,7 +235,7 @@ public function testCanRunGatesSeparately(): void $gateDeny = $this->createTestGate('second gate', GateResult::DENY); $config = new Configuration([$gateAllow, $gateDeny], []); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); // ensure we expect the gate events $mockDispatcher = $this->createMock(EventDispatcher::class); $mockDispatcher @@ -265,7 +266,7 @@ public function testCanRunActionsSeparately(): void $config = new Configuration([], [$action]); $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); $mockDispatcher = $this->createMock(EventDispatcher::class); $mockDispatcher @@ -293,7 +294,7 @@ public function testCanNotRunActionsWithoutGateBeingExecuted(): void $config = new Configuration([$gate], [$action]); $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); $worker = new StateWorker($context, new NullEventDispatcher()); @@ -308,7 +309,7 @@ public function testRunNextActionExecutesSingleAction(): void $config = new Configuration([], [$action]); $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); $mockDispatcher = $this->createMock(EventDispatcher::class); $mockDispatcher @@ -333,7 +334,7 @@ public function testRunNextActionReturnsContextImmediatelyWhenNoMoreActions(): v { $config = new Configuration([], []); // No actions $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); $mockDispatcher = new NullEventDispatcher(); @@ -351,7 +352,7 @@ public function testExecuteNextActionDoesNotExecuteWhenWorkflowIsStopped(): void $config = new Configuration([], [$action]); $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); $context->markAsStopped(); // Mark as stopped $mockDispatcher = new NullEventDispatcher(); @@ -376,7 +377,7 @@ public function execute(ActionContext $context): ActionResult $config = new Configuration([], [$pauseAction, $secondAction]); $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); $mockDispatcher = new NullEventDispatcher(); @@ -400,7 +401,7 @@ public function execute(ActionContext $context): ActionResult $config = new Configuration([], [$stopAction, $secondAction]); $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); $mockDispatcher = new NullEventDispatcher(); @@ -430,7 +431,7 @@ public function message(): string $config = new Configuration([$invalidGate], []); $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); $worker = new StateWorker($context, new NullEventDispatcher()); @@ -461,7 +462,7 @@ public function gate(): Gate $config = new Configuration([], [$guardedAction]); $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); $mockDispatcher = new NullEventDispatcher(); @@ -496,7 +497,7 @@ public function gate(): Gate $config = new Configuration([], [$guardedAction]); $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); $mockDispatcher = new NullEventDispatcher(); @@ -518,7 +519,7 @@ public function testSetNextActionIndexAllowsJumpingToSpecificAction(): void $config = new Configuration([], [$action1, $action2, $action3]); $state = $this->createTestState(['status' => 'draft']); - $context = new TransitionContext($state, ['status' => 'published'], $config); + $context = new TransitionContext($state, new ArrayDelta(['status' => 'published']), $config); $mockDispatcher = new NullEventDispatcher(); diff --git a/tests/Unit/TransitionContextSerializationTest.php b/tests/Unit/TransitionContextSerializationTest.php index 96bd446..597291a 100644 --- a/tests/Unit/TransitionContextSerializationTest.php +++ b/tests/Unit/TransitionContextSerializationTest.php @@ -10,6 +10,7 @@ use BenRowe\StateFlow\Action\ExecutionState; use BenRowe\StateFlow\ActionFactory; use BenRowe\StateFlow\ActionSkip; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateContext; @@ -44,7 +45,7 @@ public function testSerializeMinimalContext(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $serialized = $context->serialize(); @@ -58,7 +59,7 @@ public function testSerializeAndUnserializeMinimalContext(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $serialized = $context->serialize(); $restored = TransitionContext::unserialize( @@ -70,7 +71,7 @@ public function testSerializeAndUnserializeMinimalContext(): void $this->assertEquals($state->toArray(), $restored->getCurrentState()->toArray()); $this->assertEquals($state->toArray(), $restored->getInitialState()->toArray()); - $this->assertSame($delta, $restored->getDesiredDelta()); + $this->assertSame($delta, $restored->getDesiredDelta()->asArray()); } public function testSerializeAndUnserializeWithGates(): void @@ -80,7 +81,7 @@ public function testSerializeAndUnserializeWithGates(): void $gate = new TestGate(); $config = new Configuration([$gate], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->addGateEvaluation($gate, GateResult::ALLOW, false); $serialized = $context->serialize(); @@ -106,7 +107,7 @@ public function testSerializeAndUnserializeWithActions(): void $action = new TestAction(); $config = new Configuration([], [$action]); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $newState = new TestState(['status' => 'published']); $context->addActionResult(new ActionResult(ExecutionState::CONTINUE, $newState)); @@ -130,7 +131,7 @@ public function testSerializeAndUnserializeWithPausedStatus(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->markAsPaused(); $serialized = $context->serialize(); @@ -152,7 +153,7 @@ public function testSerializeAndUnserializeWithStoppedStatus(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->markAsStopped(); $serialized = $context->serialize(); @@ -174,7 +175,7 @@ public function testSerializeAndUnserializeWithLockState(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $lockState = new LockState('order:123', 1234567890.0, 30); $context->setLockState($lockState); @@ -200,7 +201,7 @@ public function testSerializeAndUnserializeWithActionSkips(): void $action = new TestAction(); $config = new Configuration([], [$action]); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->addActionSkip($action, GateResult::DENY); $serialized = $context->serialize(); @@ -224,7 +225,7 @@ public function testSerializeAndUnserializeWithMetadata(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $metadata = ['reason' => 'waiting for approval', 'approver' => 'manager@example.com']; $context->addActionResult(ActionResult::pause(null, $metadata)); @@ -248,7 +249,7 @@ public function testSerializeAndUnserializeWithSkippedDueToLock(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->markAsSkippedDueToLock(); $serialized = $context->serialize(); @@ -272,7 +273,7 @@ public function testSerializeAndUnserializeCompleteWorkflow(): void $action = new TestAction(); $config = new Configuration([$gate], [$action]); - $context = new TransitionContext($initialState, $delta, $config); + $context = new TransitionContext($initialState, new ArrayDelta($delta), $config); $context->updateCurrentState($currentState); $context->addGateEvaluation($gate, GateResult::ALLOW, false); $context->addActionResult(new ActionResult(ExecutionState::CONTINUE, $currentState)); @@ -292,7 +293,7 @@ public function testSerializeAndUnserializeCompleteWorkflow(): void // Verify state $this->assertEquals($initialState->toArray(), $restored->getInitialState()->toArray()); $this->assertEquals($currentState->toArray(), $restored->getCurrentState()->toArray()); - $this->assertSame($delta, $restored->getDesiredDelta()); + $this->assertSame($delta, $restored->getDesiredDelta()->asArray()); // Verify configuration $this->assertCount(1, $restored->getConfiguration()->getTransitionGates()); @@ -330,7 +331,7 @@ public function testUnserializeWithMissingConfigurationData(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $serialized = $context->serialize(); // Decode and remove configuration @@ -362,7 +363,7 @@ public function testUnserializeWithInvalidConfigurationData(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $serialized = $context->serialize(); // Decode and set configuration to a non-array value @@ -394,7 +395,7 @@ public function testUnserializeWithNullLockStateData(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $serialized = $context->serialize(); // Decode and set lockState to null @@ -425,7 +426,7 @@ public function testUnserializeWithMissingSkippedDueToLock(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $serialized = $context->serialize(); // Decode and remove skippedDueToLock @@ -456,7 +457,7 @@ public function testUnserializeWithNullStatus(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $serialized = $context->serialize(); // Decode and set status to null @@ -489,7 +490,7 @@ public function testUnserializeWithInvalidStatusUsesDefault(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->markAsCompleted(); $serialized = $context->serialize(); @@ -524,7 +525,7 @@ public function testUnserializeWithInvalidGateResultUsesDefault(): void $gate = new TestGate(); $config = new Configuration([$gate], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->addGateEvaluation($gate, GateResult::ALLOW, false); $serialized = $context->serialize(); @@ -558,7 +559,7 @@ public function testUnserializeWithInvalidActionExecutionStateUsesDefault(): voi $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->addActionResult(new ActionResult(ExecutionState::CONTINUE)); $serialized = $context->serialize(); @@ -593,7 +594,7 @@ public function testUnserializeWithInvalidActionSkipGateResultUsesDefault(): voi $action = new TestAction(); $config = new Configuration([], [$action]); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->addActionSkip($action, GateResult::DENY); $serialized = $context->serialize(); @@ -628,7 +629,7 @@ public function testSerializeAndUnserializeWithGateDenyResult(): void $gate = new TestGate(); $config = new Configuration([$gate], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->addGateEvaluation($gate, GateResult::DENY, false); $serialized = $context->serialize(); @@ -651,7 +652,7 @@ public function testSerializeAndUnserializeWithGateSkipIdempotentResult(): void $gate = new TestGate(); $config = new Configuration([$gate], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->addGateEvaluation($gate, GateResult::SKIP_IDEMPOTENT, false); $serialized = $context->serialize(); @@ -674,7 +675,7 @@ public function testSerializeAndUnserializeWithActionSkipAllowResult(): void $action = new TestAction(); $config = new Configuration([], [$action]); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->addActionSkip($action, GateResult::ALLOW); $serialized = $context->serialize(); @@ -697,7 +698,7 @@ public function testSerializeAndUnserializeWithActionSkipIdempotentResult(): voi $action = new TestAction(); $config = new Configuration([], [$action]); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->addActionSkip($action, GateResult::SKIP_IDEMPOTENT); $serialized = $context->serialize(); @@ -719,7 +720,7 @@ public function testSerializeAndUnserializeWithPauseExecutionState(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->addActionResult(new ActionResult(ExecutionState::PAUSE)); $serialized = $context->serialize(); @@ -741,7 +742,7 @@ public function testSerializeAndUnserializeWithStopExecutionState(): void $delta = ['status' => 'published']; $config = new Configuration([], []); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->addActionResult(new ActionResult(ExecutionState::STOP)); $serialized = $context->serialize(); @@ -764,7 +765,7 @@ public function testSerializePreservesBothConfigurationActionsAndActionExecution $action = new TestAction(); $config = new Configuration([], [$action]); - $context = new TransitionContext($state, $delta, $config); + $context = new TransitionContext($state, new ArrayDelta($delta), $config); $context->addActionResult(new ActionResult(ExecutionState::CONTINUE, new TestState(['status' => 'published']))); $serialized = $context->serialize(); diff --git a/tests/Unit/TransitionContextTest.php b/tests/Unit/TransitionContextTest.php index 8c22758..ffbb6de 100644 --- a/tests/Unit/TransitionContextTest.php +++ b/tests/Unit/TransitionContextTest.php @@ -7,6 +7,7 @@ use BenRowe\StateFlow\Action\Action; use BenRowe\StateFlow\Action\ActionResult; use BenRowe\StateFlow\ActionSkip; +use BenRowe\StateFlow\ArrayDelta; use BenRowe\StateFlow\Configuration\Configuration; use BenRowe\StateFlow\Gate\Gate; use BenRowe\StateFlow\Gate\GateResult; @@ -38,27 +39,27 @@ protected function setUp(): void public function testConstructWithoutDelta(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $this->assertSame($this->mockState, $context->getCurrentState()); $this->assertSame($this->mockState, $context->getInitialState()); - $this->assertSame([], $context->getDesiredDelta()); + $this->assertSame([], $context->getDesiredDelta()->asArray()); $this->assertSame($this->mockConfiguration, $context->getConfiguration()); } public function testConstructWithDelta(): void { $delta = ['status' => 'published', 'priority' => 'high']; - $context = new TransitionContext($this->mockState, $delta, $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta($delta), $this->mockConfiguration); $this->assertSame($this->mockState, $context->getCurrentState()); $this->assertSame($this->mockState, $context->getInitialState()); - $this->assertSame($delta, $context->getDesiredDelta()); + $this->assertSame($delta, $context->getDesiredDelta()->asArray()); } public function testUpdateCurrentState(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $newState = $this->createStub(State::class); $context->updateCurrentState($newState); @@ -70,21 +71,21 @@ public function testUpdateCurrentState(): void public function testGetDesiredDelta(): void { $delta = ['foo' => 'bar', 'baz' => 123]; - $context = new TransitionContext($this->mockState, $delta, $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta($delta), $this->mockConfiguration); - $this->assertSame($delta, $context->getDesiredDelta()); + $this->assertSame($delta, $context->getDesiredDelta()->asArray()); } public function testActionExecutionsEmptyByDefault(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $this->assertSame([], $context->getActionExecutions()); } public function testAddActionResult(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $result1 = ActionResult::continue(); $result2 = ActionResult::pause(); @@ -99,14 +100,14 @@ public function testAddActionResult(): void public function testGateEvaluationsEmptyByDefault(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $this->assertSame([], $context->getGateEvaluations()); } public function testAddGateEvaluation(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $gate1 = $this->createStub(Gate::class); $gate2 = $this->createStub(Gate::class); @@ -129,14 +130,14 @@ public function testAddGateEvaluation(): void public function testActionSkipsEmptyByDefault(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $this->assertSame([], $context->getActionSkips()); } public function testAddActionSkip(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $action1 = $this->createStub(Action::class); $action2 = $this->createStub(Action::class); @@ -157,7 +158,7 @@ public function testAddActionSkip(): void public function testStatusDefaultsToNull(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $this->assertFalse($context->isCompleted()); $this->assertFalse($context->isPaused()); @@ -166,7 +167,7 @@ public function testStatusDefaultsToNull(): void public function testMarkAsCompleted(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $context->markAsCompleted(); @@ -177,7 +178,7 @@ public function testMarkAsCompleted(): void public function testMarkAsPaused(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $context->markAsPaused(); @@ -188,7 +189,7 @@ public function testMarkAsPaused(): void public function testMarkAsStopped(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $context->markAsStopped(); @@ -199,7 +200,7 @@ public function testMarkAsStopped(): void public function testStatusCanBeChangedMultipleTimes(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); // Initially null $this->assertFalse($context->isCompleted()); @@ -223,7 +224,7 @@ public function testStatusCanBeChangedMultipleTimes(): void public function testCompleteWorkflow(): void { - $context = new TransitionContext($this->mockState, ['status' => 'published'], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta(['status' => 'published']), $this->mockConfiguration); // Add gate evaluations $gate = $this->createStub(Gate::class); @@ -241,13 +242,13 @@ public function testCompleteWorkflow(): void $this->assertCount(1, $context->getActionExecutions()); $this->assertCount(0, $context->getActionSkips()); $this->assertTrue($context->isCompleted()); - $this->assertSame(['status' => 'published'], $context->getDesiredDelta()); + $this->assertSame(['status' => 'published'], $context->getDesiredDelta()->asArray()); } public function testDidGatePassEmpty(): void { $config = new Configuration([], []); - $context = new TransitionContext($this->mockState, ['status' => 'published'], $config); + $context = new TransitionContext($this->mockState, new ArrayDelta(['status' => 'published']), $config); $this->assertTrue($context->didGatesPass()); } @@ -256,7 +257,7 @@ public function testDidGatePassAllPass(): void $gate1 = $this->createTestGate('gate 1', GateResult::ALLOW); $gate2 = $this->createTestGate('gate 2', GateResult::ALLOW); $config = new Configuration([$gate1, $gate2], []); - $context = new TransitionContext($this->mockState, ['status' => 'published'], $config); + $context = new TransitionContext($this->mockState, new ArrayDelta(['status' => 'published']), $config); $context->addGateEvaluation($gate1, GateResult::ALLOW, false); $context->addGateEvaluation($gate2, GateResult::ALLOW, false); @@ -268,7 +269,7 @@ public function testDidGatePassASomePass(): void $gate1 = $this->createTestGate('gate 1', GateResult::ALLOW); $gate2 = $this->createTestGate('gate 2', GateResult::DENY); $config = new Configuration([$gate1, $gate2], []); - $context = new TransitionContext($this->mockState, ['status' => 'published'], $config); + $context = new TransitionContext($this->mockState, new ArrayDelta(['status' => 'published']), $config); $context->addGateEvaluation($gate1, GateResult::ALLOW, false); $context->addGateEvaluation($gate2, GateResult::DENY, false); @@ -280,7 +281,7 @@ public function testDidGatePassFail(): void $gate1 = $this->createTestGate('gate 1', GateResult::ALLOW); $gate2 = $this->createTestGate('gate 2', GateResult::DENY); $config = new Configuration([$gate1, $gate2], []); - $context = new TransitionContext($this->mockState, ['status' => 'published'], $config); + $context = new TransitionContext($this->mockState, new ArrayDelta(['status' => 'published']), $config); $context->addGateEvaluation($gate1, GateResult::DENY, false); $this->assertFalse($context->didGatesPass()); @@ -288,7 +289,7 @@ public function testDidGatePassFail(): void public function testClearPauseStatusWhenPaused(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $context->markAsPaused(); $this->assertTrue($context->isPaused()); @@ -302,7 +303,7 @@ public function testClearPauseStatusWhenPaused(): void public function testClearPauseStatusWhenNotPausedDoesNothing(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $context->markAsCompleted(); $this->assertTrue($context->isCompleted()); @@ -316,7 +317,7 @@ public function testClearPauseStatusWhenNotPausedDoesNothing(): void public function testGetLockStateReturnsDefaultEmptyState(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $lockState = $context->getLockState(); @@ -326,7 +327,7 @@ public function testGetLockStateReturnsDefaultEmptyState(): void public function testSetLockStateUpdatesState(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $lockState = new LockState('order:123', 1234567890.0, 30); $context->setLockState($lockState); @@ -339,14 +340,14 @@ public function testSetLockStateUpdatesState(): void public function testWasSkippedDueToLockDefaultsToFalse(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $this->assertFalse($context->wasSkippedDueToLock()); } public function testMarkAsSkippedDueToLockSetsFlag(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $context->markAsSkippedDueToLock(); @@ -355,14 +356,14 @@ public function testMarkAsSkippedDueToLockSetsFlag(): void public function testGetStatusMetadataReturnsNullWhenNoActionsExecuted(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $this->assertNull($context->getStatusMetadata()); } public function testGetStatusMetadataReturnsNullWhenNoStopOrPauseActions(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $context->addActionResult(ActionResult::continue()); $context->addActionResult(ActionResult::continue()); @@ -371,7 +372,7 @@ public function testGetStatusMetadataReturnsNullWhenNoStopOrPauseActions(): void public function testGetStatusMetadataReturnsPauseMetadata(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $metadata = ['reason' => 'waiting for approval', 'approver' => 'manager@example.com']; $context->addActionResult(ActionResult::continue()); @@ -382,7 +383,7 @@ public function testGetStatusMetadataReturnsPauseMetadata(): void public function testGetStatusMetadataReturnsStopMetadata(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $metadata = ['error' => 'Payment failed', 'code' => 'CARD_DECLINED']; $context->addActionResult(ActionResult::continue()); @@ -393,7 +394,7 @@ public function testGetStatusMetadataReturnsStopMetadata(): void public function testGetStatusMetadataReturnsLastPauseOrStopMetadata(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $metadata1 = ['first' => 'pause']; $metadata2 = ['second' => 'stop']; @@ -407,7 +408,7 @@ public function testGetStatusMetadataReturnsLastPauseOrStopMetadata(): void public function testGetStatusMetadataReturnsNullWhenMetadataIsNull(): void { - $context = new TransitionContext($this->mockState, [], $this->mockConfiguration); + $context = new TransitionContext($this->mockState, new ArrayDelta([]), $this->mockConfiguration); $context->addActionResult(ActionResult::pause(null, null)); From 31f0f275f33711873289594825f3841ea8cc46ef Mon Sep 17 00:00:00 2001 From: Ben Rowe Date: Thu, 11 Dec 2025 20:55:20 +1100 Subject: [PATCH 10/10] Update documentation --- CLAUDE.md | 8 +- README.md | 18 ++--- docs/architecture.md | 14 ++-- docs/core-concepts.md | 180 ++++++++++++++++++++++++++++++++++++++---- docs/examples.md | 48 +++++------ docs/interfaces.md | 53 +++++++++++-- docs/locking.md | 40 +++++----- docs/observability.md | 6 +- 8 files changed, 278 insertions(+), 89 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9efa27a..154f513 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ Only consider your changes complete when `make check` passes with all green chec StateFlow uses a **two-step execution model** that separates setup from execution: -1. **Setup Phase**: `StateFlow::transition(State, array $delta)` returns a `StateWorker` +1. **Setup Phase**: `StateFlow::transition(State, Delta $delta)` returns a `StateWorker` 2. **Execution Phase**: `StateWorker::execute()` runs the transition This separation enables: @@ -131,15 +131,15 @@ interface State { } ``` -Users define their own merge strategy in `with()`. The `$changes` array is the delta passed to `transition()`. +Users define their own merge strategy in `with()`. The `$changes` array is converted from the Delta object passed to `transition()`. ### Configuration Provider Lazy-loaded configuration based on current state and desired delta: ```php -$configProvider = function(State $currentState, array $delta): Configuration { - return match ($delta['status'] ?? null) { +$configProvider = function(State $currentState, Delta $delta): Configuration { + return match ($delta->get('status')) { 'processing' => new Configuration( transitionGates: [new HasInventoryGate()], actions: [new ChargePaymentAction(), new ReserveInventoryAction()], diff --git a/README.md b/README.md index 16d2327..0278920 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ $stateFlow = new StateFlow( // Execute transition with automatic locking $order = new Order('pending'); -$worker = $stateFlow->transition($order, ['status' => 'processing']); +$worker = $stateFlow->transition($order, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); @@ -98,12 +98,12 @@ Specify only what changes: ```php // Just this -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); // Not this -$worker = $stateFlow->transition($state, ['status' => 'published', 'author' => 'same', 'created' => 'same', ...]); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published', 'author' => 'same', 'created' => 'same', ...])); $context = $worker->execute(); ``` @@ -112,7 +112,7 @@ $context = $worker->execute(); The `StateWorker` gives you full control over the workflow execution: ```php -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); // 1. Run gates first $gateResult = $worker->runGates(); @@ -149,7 +149,7 @@ $stateFlow = new StateFlow( ); // This transition will be automatically locked -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); ``` @@ -249,8 +249,8 @@ class OrderState implements State { } // 2. Configure workflow based on transition type -$configProvider = function(State $state, array $delta): Configuration { - return match ($delta['status'] ?? null) { +$configProvider = function(State $state, Delta $delta): Configuration { + return match ($delta->get('status')) { 'processing' => new Configuration( transitionGates: [new HasInventoryGate($inventory)], actions: [ @@ -273,7 +273,7 @@ $stateFlow = new StateFlow( eventDispatcher: new MetricsDispatcher(), lockProvider: new RedisLockProvider($redis), lockKeyProvider: new class implements LockKeyProvider { - public function getLockKey(State $state, array $delta): string { + public function getLockKey(State $state, Delta $delta): string { return "order:" . $state->toArray()['id']; } }, @@ -282,7 +282,7 @@ $stateFlow = new StateFlow( // 4. Execute with race protection try { $order = new OrderState('ORD-123', 'pending', 99.99); - $worker = $stateFlow->transition($order, ['status' => 'processing']); + $worker = $stateFlow->transition($order, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); if ($context->isCompleted()) { diff --git a/docs/architecture.md b/docs/architecture.md index 42f9419..ff30675 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,11 +18,11 @@ Users specify only what should change, not the entire final state: ```php // Good: Delta approach -$worker = $stateFlow->transition($orderState, ['status' => 'published']); +$worker = $stateFlow->transition($orderState, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); // Avoided: Full state (verbose and error-prone) -$stateFlow->transition($orderState, ['status' => 'published', 'author' => 'same', 'created' => 'same', ...]); +$stateFlow->transition($orderState, new ArrayDelta(['status' => 'published', 'author' => 'same', 'created' => 'same', ...])); ``` **Rationale:** With rich state objects, deltas are more ergonomic and show clear intent. @@ -48,14 +48,14 @@ The `StateWorker` allows for fine-grained control over the execution flow. **One-Shot Execution:** ```php -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); // Runs gates and actions ``` **Step-Through Execution:** ```php -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $gateResult = $worker->runGates(); if ($gateResult->shouldStopTransition()) { // Handle failed transition @@ -75,7 +75,7 @@ class GenerateThumbnailsAction implements Action { } // Serialize and wait -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); if ($context->isPaused()) { saveToDatabase($context->serialize()); @@ -120,7 +120,7 @@ $stateFlow = new StateFlow( ); // The worker will use the flows's lock provider -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); ``` @@ -210,7 +210,7 @@ Users implement their own merge logic in `with()`. **Why:** Allows dynamic gate/action selection based on what's changing. ```php -$configProvider = function(State $currentState, array $desiredDelta): Configuration { +$configProvider = function(State $currentState, Delta $desiredDelta): Configuration { // ... return Configuration based on state and delta }; ``` diff --git a/docs/core-concepts.md b/docs/core-concepts.md index 0aad8ac..4193953 100644 --- a/docs/core-concepts.md +++ b/docs/core-concepts.md @@ -1,5 +1,157 @@ # Core Concepts +## Delta + +### Interface Definition + +```php +interface Delta +{ + /** + * Get a value from the delta by key + */ + public function get(string $key, mixed $default = null): mixed; + + /** + * Check if a key exists in the delta + */ + public function has(string $key): bool; + + /** + * Get all delta values as an array + */ + public function asArray(): array; +} +``` + +### Design Rationale + +**Why a Delta interface?** + +- Provides a clean API for accessing delta values without array access syntax +- Allows for different delta implementations (array-based, immutable, validated, etc.) +- Type-safe access to delta values +- Clear intent: this is a set of changes, not a full state + +### ArrayDelta Implementation + +StateFlow provides `ArrayDelta` as the default implementation: + +```php +use BenRowe\StateFlow\Delta\ArrayDelta; + +// Create a delta +$delta = new ArrayDelta(['status' => 'published', 'publishedAt' => '2025-01-01']); + +// Access values +$status = $delta->get('status'); // 'published' +$author = $delta->get('author', 'unknown'); // 'unknown' (default) + +// Check existence +if ($delta->has('status')) { + // ... +} + +// Get as array (for State::with()) +$changes = $delta->asArray(); // ['status' => 'published', 'publishedAt' => '2025-01-01'] +``` + +### Usage in Transitions + +```php +use BenRowe\StateFlow\Delta\ArrayDelta; + +$state = new OrderState('ORD-123', 'pending', 99.99); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); +$context = $worker->execute(); +``` + +### Usage in Configuration Providers + +```php +$configProvider = function(State $state, Delta $delta): Configuration { + // Access delta values using ->get() + return match ($delta->get('status')) { + 'published' => new Configuration( + transitionGates: [new CanPublishGate()], + actions: [new PublishAction()], + ), + 'archived' => new Configuration( + transitionGates: [new CanArchiveGate()], + actions: [new ArchiveAction()], + ), + default => new Configuration(), + }; +}; +``` + +### Usage in Gates + +```php +class HasRequiredFieldsGate implements Gate +{ + public function evaluate(GateContext $context): GateResult + { + // Access delta via context + $delta = $context->desiredDelta; + + // Use ->get() to access values + if ($delta->has('status') && $delta->get('status') === 'published') { + $final = $context->currentState->with($delta->asArray()); + $data = $final->toArray(); + + if (empty($data['content'])) { + return GateResult::DENY; + } + } + + return GateResult::ALLOW; + } +} +``` + +### Custom Delta Implementations + +You can create custom Delta implementations for specific use cases: + +```php +// Validated delta that ensures only allowed fields +class ValidatedDelta implements Delta +{ + public function __construct( + private array $data, + private array $allowedFields + ) { + foreach (array_keys($data) as $key) { + if (!in_array($key, $allowedFields)) { + throw new \InvalidArgumentException("Field '$key' is not allowed"); + } + } + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->data[$key] ?? $default; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->data); + } + + public function asArray(): array + { + return $this->data; + } +} + +// Usage +$delta = new ValidatedDelta( + ['status' => 'published'], + allowedFields: ['status', 'publishedAt'] +); +``` + ## State ### Interface Definition @@ -158,7 +310,7 @@ class GateContext { public function __construct( public readonly State $currentState, - public readonly array $desiredDelta, + public readonly Delta $desiredDelta, ) {} } ``` @@ -273,7 +425,7 @@ class HasRequiredFieldsGate implements Gate { public function evaluate(GateContext $context): GateResult { - $final = $context->currentState->with($context->desiredDelta); + $final = $context->currentState->with($context->desiredDelta->asArray()); $data = $final->toArray(); $required = ['title', 'content', 'author']; @@ -301,7 +453,7 @@ class NotAlreadyPublishedGate implements Gate // If already in the desired state, skip actions but succeed // Use SKIP_IDEMPOTENT instead of DENY so the transition completes successfully // without re-running actions that would be redundant - if (isset($desired['status']) && $current['status'] === $desired['status']) { + if ($desired->has('status') && $current['status'] === $desired->get('status')) { return GateResult::SKIP_IDEMPOTENT; } @@ -364,7 +516,7 @@ class ActionContext { public function __construct( public readonly State $currentState, - public readonly array $desiredDelta, + public readonly Delta $desiredDelta, public readonly TransitionContext $executionContext, ) {} } @@ -510,7 +662,7 @@ interface ConfigurationProvider * Provide configuration for a state transition * Lazy-loaded based on current state and desired changes */ - public function provide(State $currentState, array $desiredDelta): Configuration; + public function provide(State $currentState, Delta $desiredDelta): Configuration; } class Configuration @@ -536,10 +688,10 @@ class Configuration **Solution:** Load configuration based on what's changing. ```php -$configProvider = function(State $currentState, array $desiredDelta): Configuration { +$configProvider = function(State $currentState, Delta $desiredDelta): Configuration { // Dynamic configuration based on transition - if (isset($desiredDelta['status'])) { - return match ($desiredDelta['status']) { + if ($desiredDelta->has('status')) { + return match ($desiredDelta->get('status')) { 'published' => new Configuration( transitionGates: [ new CanPublishGate(), @@ -564,7 +716,7 @@ $configProvider = function(State $currentState, array $desiredDelta): Configurat } // Metadata-only changes - if (isset($desiredDelta['metadata'])) { + if ($desiredDelta->has('metadata')) { return new Configuration( actions: [new UpdateMetadataAction()], ); @@ -576,7 +728,7 @@ $configProvider = function(State $currentState, array $desiredDelta): Configurat $stateFlow = new StateFlow(configProvider: $configProvider); // Now the flow can be used for any state object -$worker = $stateFlow->transition($someOrderState, ['status' => 'published']); +$worker = $stateFlow->transition($someOrderState, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); ``` @@ -602,7 +754,7 @@ class StateFlow */ public function transition( State $currentState, - array $desiredDelta + Delta $desiredDelta ): StateWorker; /** @@ -624,7 +776,7 @@ $stateFlow = new StateFlow( ); $initialState = new OrderState(/* ... */); -$worker = $stateFlow->transition($initialState, ['status' => 'published']); +$worker = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); if ($context->isCompleted()) { @@ -636,7 +788,7 @@ if ($context->isCompleted()) { **Step-by-Step Execution:** ```php -$worker = $stateFlow->transition($initialState, ['status' => 'published']); +$worker = $stateFlow->transition($initialState, new ArrayDelta(['status' => 'published'])); $gateResult = $worker->runGates(); @@ -664,7 +816,7 @@ class TransitionContext { // State access public function getCurrentState(): State; - public function getDesiredDelta(): array; + public function getDesiredDelta(): Delta; // Status checks public function isCompleted(): bool; diff --git a/docs/examples.md b/docs/examples.md index f7a9825..91516ca 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -48,8 +48,8 @@ class ArticleState implements State } // 2. Create configuration -$config = function(State $state, array $delta): Configuration { - if (isset($delta['status']) && $delta['status'] === 'published') { +$config = function(State $state, Delta $delta): Configuration { + if ($delta->has('status') && $delta->get('status') === 'published') { return new Configuration( transitionGates: [new CanPublishGate()], actions: [new PublishAction()], @@ -66,7 +66,7 @@ $stateFlow = new StateFlow( // 4. Execute transition $state = new ArticleState('draft'); -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); // 5. Check result @@ -237,13 +237,13 @@ class OrderConfigurationProvider implements ConfigurationProvider private ShippingService $shipping, ) {} - public function provide(State $currentState, array $desiredDelta): Configuration + public function provide(State $currentState, Delta $desiredDelta): Configuration { - if (!isset($desiredDelta['status'])) { + if (!$desiredDelta->has('status')) { return new Configuration(); } - return match ($desiredDelta['status']) { + return match ($desiredDelta->get('status')) { 'processing' => new Configuration( transitionGates: [new HasPaymentGate()], actions: [ @@ -275,7 +275,7 @@ $stateFlow = new StateFlow( eventDispatcher: new OrderEventDispatcher(), lockProvider: new RedisLockProvider($redis), lockKeyProvider: new class implements LockKeyProvider { - public function getLockKey(State $state, array $delta): string { + public function getLockKey(State $state, Delta $delta): string { return "order:{$state->toArray()['id']}"; } }, @@ -288,7 +288,7 @@ try { status: 'pending', total: 99.99, ); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); if ($context->isCompleted()) { @@ -340,8 +340,8 @@ class ContentState implements State // Publishing workflow $stateFlow = new StateFlow( - configProvider: function(State $state, array $delta): Configuration { - if (isset($delta['status']) && $delta['status'] === 'published') { + configProvider: function(State $state, Delta $delta): Configuration { + if ($delta->has('status') && $delta->get('status') === 'published') { return new Configuration( transitionGates: [ new HasContentGate(), @@ -365,7 +365,7 @@ $stateFlow = new StateFlow( // Publish with automatic pause for thumbnails $state = new ContentState('article-1', 'draft', 'Content here'); -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); if ($context->isPaused()) { @@ -428,7 +428,7 @@ $stateFlow = new StateFlow( // Start workflow $state = new VideoState('pending'); -$worker = $stateFlow->transition($state, ['status' => 'processing']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); if ($context->isPaused()) { @@ -495,7 +495,7 @@ $stateFlow = new StateFlow( ); $state = new ArticleState('draft'); -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); // Run gates first $gateResult = $worker->runGates(); @@ -525,7 +525,7 @@ foreach ($context->getActionExecutions() as $exec) { ### Gate Failures ```php -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); if ($context->isStopped()) { @@ -567,7 +567,7 @@ class SafeAction implements Action ```php try { - $worker = $stateFlow->transition($state, ['status' => 'published']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); } catch (LockAcquisitionException $e) { @@ -576,7 +576,7 @@ try { // Retry with exponential backoff retry(3, function() use ($stateFlow, $state) { - $worker = $stateFlow->transition($state, ['status' => 'published']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); return $worker->execute(); }, 1000); } @@ -586,7 +586,7 @@ try { ```php try { - $worker = $stateFlow->transition($state, ['status' => 'published']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); } catch (LockAcquisitionException $e) { @@ -630,7 +630,7 @@ class OrderWorkflowTest extends TestCase ); $state = new OrderState('ORD-123', 'pending', 99.99); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); $this->assertTrue($context->isCompleted()); @@ -651,7 +651,7 @@ public function test_cannot_publish_without_content() ); $state = new ArticleState('draft', content: ''); - $worker = $stateFlow->transition($state, ['status' => 'published']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); $this->assertTrue($context->isStopped()); @@ -681,7 +681,7 @@ public function test_idempotent_transition_succeeds_without_running_actions() // Already in 'published' state $state = new ArticleState('published'); - $worker = $stateFlow->transition($state, ['status' => 'published']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); // Transition completes successfully @@ -729,13 +729,13 @@ public function test_concurrent_transitions_prevented() $state = new OrderState('ORD-123', 'pending', 99.99); // Flow 1 acquires lock - $worker1 = $stateFlow1->transition($state, ['status' => 'published']); + $worker1 = $stateFlow1->transition($state, new ArrayDelta(['status' => 'published'])); $context1 = $worker1->execute(); $this->assertTrue($context1->getLockState()->isLocked()); // Flow 2 fails to acquire lock $this->expectException(LockAcquisitionException::class); - $worker2 = $stateFlow2->transition($state, ['status' => 'published']); + $worker2 = $stateFlow2->transition($state, new ArrayDelta(['status' => 'published'])); $worker2->execute(); } ``` @@ -755,7 +755,7 @@ public function test_workflow_can_pause_and_resume() ); $state = new VideoState('pending'); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $context = $worker->execute(); $this->assertTrue($context->isPaused()); @@ -788,7 +788,7 @@ public function test_events_are_dispatched() ); $state = new OrderState('ORD-123', 'pending', 99.99); - $worker = $stateFlow->transition($state, ['status' => 'processing']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'processing'])); $worker->execute(); $dispatcher->assertDispatched(TransitionStarting::class); diff --git a/docs/interfaces.md b/docs/interfaces.md index be75edc..61669d4 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -2,6 +2,43 @@ This document contains all interface definitions for the StateFlow package. +## Delta + +### Delta + +```php +interface Delta +{ + /** + * Get a value from the delta by key + */ + public function get(string $key, mixed $default = null): mixed; + + /** + * Check if a key exists in the delta + */ + public function has(string $key): bool; + + /** + * Get all delta values as an array + */ + public function asArray(): array; +} +``` + +### ArrayDelta + +```php +class ArrayDelta implements Delta +{ + public function __construct(private array $data) {} + + public function get(string $key, mixed $default = null): mixed; + public function has(string $key): bool; + public function asArray(): array; +} +``` + ## State Management ### State @@ -94,7 +131,7 @@ class GateContext { public function __construct( public readonly State $currentState, - public readonly array $desiredDelta, + public readonly Delta $desiredDelta, ) {} } ``` @@ -161,7 +198,7 @@ class ActionContext { public function __construct( public readonly State $currentState, - public readonly array $desiredDelta, + public readonly Delta $desiredDelta, public readonly TransitionContext $executionContext, ) {} } @@ -178,7 +215,7 @@ interface ConfigurationProvider * Provide configuration for a state transition * Lazy-loaded based on current state and desired changes */ - public function provide(State $currentState, array $desiredDelta): Configuration; + public function provide(State $currentState, Delta $desiredDelta): Configuration; } ``` @@ -234,7 +271,7 @@ class TransitionStarting extends Event { public function __construct( public readonly State $currentState, - public readonly array $desiredDelta, + public readonly Delta $desiredDelta, ) { parent::__construct(); } @@ -465,7 +502,7 @@ interface LockKeyProvider * - Lock specific transition: "order:123:draft->published" * - Lock state snapshot: hash($state->toArray()) */ - public function getLockKey(State $state, array $desiredDelta): string; + public function getLockKey(State $state, Delta $desiredDelta): string; } ``` @@ -547,7 +584,7 @@ class StateFlow */ public function transition( State $currentState, - array $desiredDelta + Delta $desiredDelta ): StateWorker; /** @@ -607,7 +644,7 @@ class TransitionContext implements \Serializable // State access public function getCurrentState(): State; - public function getDesiredDelta(): array; + public function getDesiredDelta(): Delta; // Status checks public function isCompleted(): bool; @@ -655,7 +692,7 @@ class CallableConfigurationProvider implements ConfigurationProvider private $callable ) {} - public function provide(State $currentState, array $desiredDelta): Configuration + public function provide(State $currentState, Delta $desiredDelta): Configuration { return ($this->callable)($currentState, $desiredDelta); } diff --git a/docs/locking.md b/docs/locking.md index 8dcb923..bef8539 100644 --- a/docs/locking.md +++ b/docs/locking.md @@ -152,7 +152,7 @@ interface LockKeyProvider * - Lock specific transition: "order:123:draft->published" * - Lock state snapshot: hash($state->toArray()) */ - public function getLockKey(State $state, array $desiredDelta): string; + public function getLockKey(State $state, Delta $desiredDelta): string; } ``` @@ -220,7 +220,7 @@ $stateFlow = new StateFlow( ); try { - $worker = $stateFlow->transition($state, ['status' => 'published']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); } catch (LockAcquisitionException $e) { // Another request is processing this entity @@ -240,7 +240,7 @@ $stateFlow = new StateFlow( lockKeyProvider: new EntityLockKeyProvider(), ); -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); ``` @@ -254,7 +254,7 @@ $stateFlow = new StateFlow( ); try { - $worker = $stateFlow->transition($state, ['status' => 'published']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); } catch (LockAcquisitionException $e) { Log::alert('Lock contention detected', ['entity' => $entity->id]); @@ -286,7 +286,7 @@ class AsyncAction implements Action { } } -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); // Lock is held @@ -313,7 +313,7 @@ Lock is released when: ```php // Automatic release on completion -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); if ($context->isCompleted()) { // Lock automatically released @@ -321,7 +321,7 @@ if ($context->isCompleted()) { // Manual release on error try { - $worker = $stateFlow->transition($state, ['status' => 'published']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); } catch (\Exception $e) { $worker->releaseLock(); // Manual cleanup @@ -335,7 +335,7 @@ For long-running paused workflows, you can manually extend the lock TTL using `L ```php // Initial transition that pauses -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); if ($context->isPaused()) { @@ -393,7 +393,7 @@ class LockState ### Accessing Lock State ```php -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); $lockState = $context->getLockState(); @@ -572,7 +572,7 @@ Lock the entire entity regardless of transition type. ```php class EntityLockKeyProvider implements LockKeyProvider { - public function getLockKey(State $state, array $desiredDelta): string + public function getLockKey(State $state, Delta $desiredDelta): string { $data = $state->toArray(); $entityId = $data['id'] ?? 'unknown'; @@ -592,12 +592,12 @@ Lock only the specific transition type. ```php class TransitionLockKeyProvider implements LockKeyProvider { - public function getLockKey(State $state, array $desiredDelta): string + public function getLockKey(State $state, Delta $desiredDelta): string { $data = $state->toArray(); $entityId = $data['id'] ?? 'unknown'; $currentStatus = $data['status'] ?? 'unknown'; - $targetStatus = $desiredDelta['status'] ?? 'unknown'; + $targetStatus = $desiredDelta->get('status', 'unknown'); return "stateflow:entity:{$entityId}:transition:{$currentStatus}->{$targetStatus}"; } @@ -615,10 +615,10 @@ Lock based on exact state contents. ```php class SnapshotLockKeyProvider implements LockKeyProvider { - public function getLockKey(State $state, array $desiredDelta): string + public function getLockKey(State $state, Delta $desiredDelta): string { $stateHash = md5(json_encode($state->toArray())); - $deltaHash = md5(json_encode($desiredDelta)); + $deltaHash = md5(json_encode($desiredDelta->asArray())); return "stateflow:snapshot:{$stateHash}:{$deltaHash}"; } @@ -643,7 +643,7 @@ Thrown when lock can't be acquired (default behavior when `lockProvider` is conf ```php try { - $worker = $stateFlow->transition($state, ['status' => 'published']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); } catch (LockAcquisitionException $e) { // Another process holds the lock @@ -661,7 +661,7 @@ try { Thrown by `runNextAction()` when lock has expired during pause. ```php -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); // Lock acquired sleep(100); // Oops, lock TTL was 30 seconds @@ -683,7 +683,7 @@ try { Thrown when resuming a paused transition if the lock no longer exists. ```php -$worker = $stateFlow->transition($state, ['status' => 'published']); +$worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); saveToDatabase($context->serialize()); @@ -795,7 +795,7 @@ class LockMonitoringDispatcher implements EventDispatcher ```php try { - $worker = $stateFlow->transition($state, ['status' => 'published']); + $worker = $stateFlow->transition($state, new ArrayDelta(['status' => 'published'])); $context = $worker->execute(); } catch (\Throwable $e) { // Always release lock on fatal errors @@ -871,13 +871,13 @@ class StateTransitionTest extends TestCase $state = new OrderState('ORD-123', 'draft', 99.99); // StateFlow 1 acquires lock - $worker1 = $stateFlow1->transition($state, ['status' => 'published']); + $worker1 = $stateFlow1->transition($state, new ArrayDelta(['status' => 'published'])); $context1 = $worker1->execute(); $this->assertTrue($context1->getLockState()->isLocked()); // StateFlow 2 fails to acquire lock $this->expectException(LockAcquisitionException::class); - $worker2 = $stateFlow2->transition($state, ['status' => 'published']); + $worker2 = $stateFlow2->transition($state, new ArrayDelta(['status' => 'published'])); $worker2->execute(); } } diff --git a/docs/observability.md b/docs/observability.md index 2921440..0414665 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -114,7 +114,7 @@ class TransitionStarting extends Event { public function __construct( public readonly State $currentState, - public readonly array $desiredDelta, + public readonly Delta $desiredDelta, ) { parent::__construct(); } @@ -492,7 +492,7 @@ class PatternMatchedDispatcher implements EventDispatcher { Log::info('Transition starting', [ 'currentState' => $event->currentState->toArray(), - 'desiredDelta' => $event->desiredDelta, + 'desiredDelta' => $event->desiredDelta->asArray(), ]); } @@ -619,7 +619,7 @@ class AuditTrailDispatcher implements EventDispatcher $this->auditLog->record([ 'user_id' => $this->user?->id, 'action' => 'state_transition', - 'before' => $event->context->getDesiredDelta(), // What changed + 'before' => $event->context->getDesiredDelta()->asArray(), // What changed 'after' => $event->finalState->toArray(), 'gates_evaluated' => $event->context->getGateEvaluations(), 'actions_executed' => $event->context->getActionExecutions(),