Skip to content

Commit 1a78175

Browse files
committed
✨ feat(transitions): Add transitions feature specification.
This commit adds comprehensive specification for the TransitionsFeature, including: - Feature registration - Transition registry - Add transition build step - Guard validation and execution - Transition evaluation and execution - Guard context - Integration scenarios - Error handling - Edge cases The specification includes acceptance criteria, intent, and test references for each component of the transitions feature.
1 parent 8506e7e commit 1a78175

34 files changed

+2058
-0
lines changed

specs/features/transitions.yaml

Lines changed: 250 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Noem\State\Tests\Integration\Feature\Transitions;
6+
7+
use Noem\State\Feature\Transitions\AddTransition;
8+
use Noem\State\RegionBuilder;
9+
use PHPUnit\Framework\Attributes\Group;
10+
use PHPUnit\Framework\TestCase;
11+
12+
/**
13+
* Acceptance Criterion: Conditional transitions with multiple guards work correctly
14+
*/
15+
#[Group('transitions')]
16+
#[Group('integration')]
17+
class ConditionalTransitionsTest extends TestCase
18+
{
19+
public function testConditionalBranchingWithMultipleGuards(): void
20+
{
21+
$builder = new RegionBuilder();
22+
23+
$region = $builder
24+
->setStates('start', 'pathA', 'pathB', 'pathC', 'end')
25+
->markInitial('start')
26+
->markFinal('end')
27+
// Branch based on priority field
28+
->addBuildStep(new AddTransition('start', 'pathA', fn(object $t): bool => ($t->priority ?? 0) === 1))
29+
->addBuildStep(new AddTransition('start', 'pathB', fn(object $t): bool => ($t->priority ?? 0) === 2))
30+
->addBuildStep(new AddTransition('start', 'pathC', fn(object $t): bool => ($t->priority ?? 0) === 3))
31+
// All paths lead to end
32+
->addBuildStep(new AddTransition('pathA', 'end'))
33+
->addBuildStep(new AddTransition('pathB', 'end'))
34+
->addBuildStep(new AddTransition('pathC', 'end'))
35+
->build();
36+
37+
// Test path A
38+
$region->trigger((object)['priority' => 1]);
39+
$this->assertTrue($region->isInState('pathA'));
40+
41+
$region->trigger((object)[]);
42+
$this->assertTrue($region->isInState('end'));
43+
}
44+
45+
public function testFallbackGuardPattern(): void
46+
{
47+
$builder = new RegionBuilder();
48+
49+
$region = $builder
50+
->setStates('start', 'special', 'default', 'end')
51+
->markInitial('start')
52+
// Try special path first, fall back to default
53+
->addBuildStep(new AddTransition('start', 'special', fn(object $t): bool => isset($t->specialCondition) && $t->specialCondition))
54+
->addBuildStep(new AddTransition('start', 'default', fn(object $t): bool => true)) // Always true fallback
55+
->build();
56+
57+
// Without special condition, should go to default
58+
$region->trigger((object)[]);
59+
$this->assertTrue($region->isInState('default'));
60+
61+
// Reset for second test
62+
$region2 = $builder->build();
63+
64+
// With special condition, should go to special
65+
$region2->trigger((object)['specialCondition' => true]);
66+
$this->assertTrue($region2->isInState('special'));
67+
}
68+
69+
public function testComplexConditionInGuard(): void
70+
{
71+
$builder = new RegionBuilder();
72+
73+
$region = $builder
74+
->setStates('idle', 'processing', 'complete')
75+
->markInitial('idle')
76+
->addBuildStep(new AddTransition('idle', 'processing', function(object $t): bool {
77+
return isset($t->data)
78+
&& is_array($t->data)
79+
&& count($t->data) > 0
80+
&& $t->ready === true;
81+
}))
82+
->addBuildStep(new AddTransition('processing', 'complete'))
83+
->build();
84+
85+
// Should not transition with incomplete trigger
86+
$region->trigger((object)['data' => []]);
87+
$this->assertTrue($region->isInState('idle'));
88+
89+
// Should transition with complete trigger
90+
$region->trigger((object)['data' => [1, 2, 3], 'ready' => true]);
91+
$this->assertTrue($region->isInState('processing'));
92+
}
93+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Noem\State\Tests\Integration\Feature\Transitions;
6+
7+
use Noem\State\Feature\Transitions\AddTransition;
8+
use Noem\State\RegionBuilder;
9+
use PHPUnit\Framework\Attributes\Group;
10+
use PHPUnit\Framework\TestCase;
11+
12+
/**
13+
* Acceptance Criterion: Transitions respect final state boundaries
14+
*/
15+
#[Group('transitions')]
16+
#[Group('integration')]
17+
class FinalStateBoundaryTest extends TestCase
18+
{
19+
public function testRespectsFinalStateBoundary(): void
20+
{
21+
$builder = new RegionBuilder();
22+
$enterCount = 0;
23+
24+
$region = $builder
25+
->setStates('one', 'two', 'three', 'final')
26+
->markInitial('one')
27+
->markFinal('final')
28+
->addBuildStep(new AddTransition('one', 'two'))
29+
->addBuildStep(new AddTransition('two', 'three'))
30+
->addBuildStep(new AddTransition('three', 'final'))
31+
// This should never happen
32+
->addBuildStep(new AddTransition('final', 'one'))
33+
->onEnter('one', function(object $t) use (&$enterCount) {
34+
$enterCount++;
35+
})
36+
->build();
37+
38+
$this->assertEquals(1, $enterCount); // Initial state entry
39+
40+
// Progress to final
41+
$region->trigger((object)[]);
42+
$region->trigger((object)[]);
43+
$region->trigger((object)[]);
44+
45+
$this->assertTrue($region->isFinal());
46+
$this->assertTrue($region->isInState('final'));
47+
48+
// Try to trigger more - should stay in final
49+
$region->trigger((object)[]);
50+
$region->trigger((object)[]);
51+
52+
$this->assertTrue($region->isInState('final'));
53+
$this->assertEquals(1, $enterCount, 'Should not re-enter initial state');
54+
}
55+
56+
public function testWorkflowCompletionDetection(): void
57+
{
58+
$builder = new RegionBuilder();
59+
$completionCallbackCalled = false;
60+
61+
$region = $builder
62+
->setStates('pending', 'processing', 'complete')
63+
->markInitial('pending')
64+
->markFinal('complete')
65+
->addBuildStep(new AddTransition('pending', 'processing'))
66+
->addBuildStep(new AddTransition('processing', 'complete'))
67+
->onEnter('complete', function(object $t) use (&$completionCallbackCalled) {
68+
$completionCallbackCalled = true;
69+
})
70+
->build();
71+
72+
$this->assertFalse($region->isFinal());
73+
$this->assertFalse($completionCallbackCalled);
74+
75+
// Progress through workflow
76+
$region->trigger((object)[]);
77+
$this->assertFalse($region->isFinal());
78+
79+
$region->trigger((object)[]);
80+
$this->assertTrue($region->isFinal());
81+
$this->assertTrue($completionCallbackCalled);
82+
}
83+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Noem\State\Tests\Unit\Feature\Transitions\AddTransition;
6+
7+
use Noem\State\Feature\Transitions\AddTransition;
8+
use PHPUnit\Framework\Attributes\Group;
9+
use PHPUnit\Framework\TestCase;
10+
11+
/**
12+
* Acceptance Criterion: AddTransition accepts from state, to state, and optional guard
13+
*/
14+
#[Group('transitions')]
15+
#[Group('add-transition-buildstep')]
16+
class ConstructorParametersTest extends TestCase
17+
{
18+
public function testAcceptsFromToStatesWithoutGuard(): void
19+
{
20+
$addTransition = new AddTransition('start', 'end');
21+
22+
$this->assertInstanceOf(AddTransition::class, $addTransition);
23+
}
24+
25+
public function testAcceptsFromToStatesWithGuard(): void
26+
{
27+
$guard = fn(object $trigger): bool => true;
28+
$addTransition = new AddTransition('start', 'end', $guard);
29+
30+
$this->assertInstanceOf(AddTransition::class, $addTransition);
31+
}
32+
33+
public function testAcceptsFromToStatesWithNullGuard(): void
34+
{
35+
$addTransition = new AddTransition('start', 'end', null);
36+
37+
$this->assertInstanceOf(AddTransition::class, $addTransition);
38+
}
39+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Noem\State\Tests\Unit\Feature\Transitions\AddTransition;
6+
7+
use Noem\State\BuildStep;
8+
use Noem\State\Feature\Transitions\AddTransition;
9+
use PHPUnit\Framework\Attributes\Group;
10+
use PHPUnit\Framework\TestCase;
11+
12+
/**
13+
* Acceptance Criterion: AddTransition implements BuildStep interface
14+
*/
15+
#[Group('transitions')]
16+
#[Group('add-transition-buildstep')]
17+
class ImplementsBuildStepTest extends TestCase
18+
{
19+
public function testImplementsBuildStepInterface(): void
20+
{
21+
$addTransition = new AddTransition('from', 'to');
22+
23+
$this->assertInstanceOf(BuildStep::class, $addTransition);
24+
}
25+
26+
public function testHasCallbackMethod(): void
27+
{
28+
$addTransition = new AddTransition('from', 'to');
29+
30+
$this->assertTrue(method_exists($addTransition, 'callback'));
31+
}
32+
33+
public function testCallbackAcceptsCorrectParameters(): void
34+
{
35+
$addTransition = new AddTransition('from', 'to');
36+
$reflection = new \ReflectionMethod($addTransition, 'callback');
37+
38+
$parameters = $reflection->getParameters();
39+
$this->assertCount(3, $parameters);
40+
41+
$this->assertEquals('builder', $parameters[0]->getName());
42+
$this->assertEquals('next', $parameters[1]->getName());
43+
$this->assertEquals('first', $parameters[2]->getName());
44+
}
45+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Noem\State\Tests\Unit\Feature\Transitions\AddTransition;
6+
7+
use Noem\State\Feature\Transitions\AddTransition;
8+
use Noem\State\Feature\Transitions\TransitionRegistry;
9+
use Noem\State\Feature\Transitions\TransitionsFeature;
10+
use Noem\State\RegionBuilder;
11+
use PHPUnit\Framework\Attributes\Group;
12+
use PHPUnit\Framework\TestCase;
13+
14+
/**
15+
* Acceptance Criterion: AddTransition passes built region to TransitionRegistry
16+
*/
17+
#[Group('transitions')]
18+
#[Group('add-transition-buildstep')]
19+
class PassesBuiltRegionTest extends TestCase
20+
{
21+
public function testPassesCorrectRegionToRegistry(): void
22+
{
23+
$builder = (new RegionBuilder())
24+
->enableFeatures(new TransitionsFeature())
25+
->setStates('a', 'b')
26+
->addBuildStep(new AddTransition('a', 'b'));
27+
28+
$region = $builder->build();
29+
$registry = $builder->chainMail->get(TransitionRegistry::class);
30+
31+
// The registry should have transitions for this specific region instance
32+
$transitions = $registry->getTransitionsForState($region, 'a');
33+
34+
$this->assertArrayHasKey('b', $transitions);
35+
}
36+
37+
public function testDifferentRegionsHaveIsolatedTransitions(): void
38+
{
39+
$builder1 = (new RegionBuilder())
40+
->enableFeatures(new TransitionsFeature())
41+
->setStates('a', 'b')
42+
->addBuildStep(new AddTransition('a', 'b'));
43+
44+
$builder2 = (new RegionBuilder())
45+
->enableFeatures(new TransitionsFeature())
46+
->setStates('x', 'y')
47+
->addBuildStep(new AddTransition('x', 'y'));
48+
49+
$region1 = $builder1->build();
50+
$region2 = $builder2->build();
51+
52+
$registry1 = $builder1->chainMail->get(TransitionRegistry::class);
53+
$registry2 = $builder2->chainMail->get(TransitionRegistry::class);
54+
55+
$transitions1 = $registry1->getTransitionsForState($region1, 'a');
56+
$transitions2 = $registry2->getTransitionsForState($region2, 'x');
57+
58+
$this->assertArrayHasKey('b', $transitions1);
59+
$this->assertArrayHasKey('y', $transitions2);
60+
$this->assertArrayNotHasKey('y', $transitions1);
61+
$this->assertArrayNotHasKey('b', $transitions2);
62+
}
63+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Noem\State\Tests\Unit\Feature\Transitions\AddTransition;
6+
7+
use Noem\State\Feature\Transitions\AddTransition;
8+
use Noem\State\Feature\Transitions\TransitionRegistry;
9+
use Noem\State\Feature\Transitions\TransitionsFeature;
10+
use Noem\State\RegionBuilder;
11+
use PHPUnit\Framework\Attributes\Group;
12+
use PHPUnit\Framework\TestCase;
13+
14+
/**
15+
* Acceptance Criterion: AddTransition registers transition in TransitionRegistry during build
16+
*/
17+
#[Group('transitions')]
18+
#[Group('add-transition-buildstep')]
19+
class RegistersInRegistryTest extends TestCase
20+
{
21+
public function testRegistersTransitionDuringBuild(): void
22+
{
23+
$builder = (new RegionBuilder())
24+
->enableFeatures(new TransitionsFeature())
25+
->setStates('idle', 'working', 'done')
26+
->addBuildStep(new AddTransition('idle', 'working'));
27+
28+
$region = $builder->build();
29+
$registry = $builder->chainMail->get(TransitionRegistry::class);
30+
31+
$transitions = $registry->getTransitionsForState($region, 'idle');
32+
33+
$this->assertArrayHasKey('working', $transitions);
34+
$this->assertCount(1, $transitions['working']);
35+
}
36+
37+
public function testRegistersMultipleTransitions(): void
38+
{
39+
$builder = (new RegionBuilder())
40+
->enableFeatures(new TransitionsFeature())
41+
->setStates('idle', 'working', 'done')
42+
->addBuildStep(new AddTransition('idle', 'working'))
43+
->addBuildStep(new AddTransition('working', 'done'));
44+
45+
$region = $builder->build();
46+
$registry = $builder->chainMail->get(TransitionRegistry::class);
47+
48+
$transitionsFromIdle = $registry->getTransitionsForState($region, 'idle');
49+
$transitionsFromWorking = $registry->getTransitionsForState($region, 'working');
50+
51+
$this->assertArrayHasKey('working', $transitionsFromIdle);
52+
$this->assertArrayHasKey('done', $transitionsFromWorking);
53+
}
54+
}

0 commit comments

Comments
 (0)