setUpComponentTest(); }); describe('Lifecycle Hooks - onMount()', function () { it('calls onMount() on initial component creation without state', function () { $called = false; $component = new class (ComponentId::fromString('test:mount'), null, $called) implements LiveComponentContract, LifecycleAware { public function __construct( private ComponentId $id, private ?ComponentData $data, private bool &$mountCalled ) { $this->data = $data ?? ComponentData::fromArray(['value' => 'test']); } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return $this->data; } public function getRenderData(): RenderData { return new RenderData('test', ['value' => $this->data->get('value')]); } public function onMount(): void { $this->mountCalled = true; } public function onUpdate(): void { } public function onDestroy(): void { } }; // ComponentRegistry should call onMount() when state is null $container = $this->container; $registry = $container->get(ComponentRegistry::class); // Simulate initial creation (no state) $resolvedComponent = $registry->resolve($component->getId(), null); expect($called)->toBeTrue(); }); it('does NOT call onMount() when re-hydrating with existing state', function () { $mountCallCount = 0; $componentClass = new class () { public static int $mountCallCount = 0; public static function create(ComponentId $id, ?ComponentData $data): object { return new class ($id, $data) implements LiveComponentContract, LifecycleAware { public function __construct( private ComponentId $id, private ?ComponentData $data ) { $this->data = $data ?? ComponentData::fromArray(['value' => 'test']); } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return $this->data; } public function getRenderData(): RenderData { return new RenderData('test', ['value' => $this->data->get('value')]); } public function onMount(): void { $GLOBALS['test_mount_count']++; } public function onUpdate(): void { } public function onDestroy(): void { } }; } }; $GLOBALS['test_mount_count'] = 0; $registry = $this->container->get(ComponentRegistry::class); $componentId = ComponentId::fromString('test:rehydrate'); // First call with null state - should call onMount() $component1 = $componentClass::create($componentId, null); $GLOBALS['test_mount_count'] = 0; // Reset $resolved1 = $registry->resolve($componentId, null); expect($GLOBALS['test_mount_count'])->toBe(1); // Second call with state - should NOT call onMount() $existingState = ComponentData::fromArray(['value' => 'rehydrated']); $component2 = $componentClass::create($componentId, $existingState); $GLOBALS['test_mount_count'] = 0; // Reset $resolved2 = $registry->resolve($componentId, $existingState); expect($GLOBALS['test_mount_count'])->toBe(0); // onMount not called unset($GLOBALS['test_mount_count']); }); }); describe('Lifecycle Hooks - onUpdate()', function () { it('calls onUpdate() after successful action execution', function () { $updateCalled = false; $component = new class (ComponentId::fromString('test:update'), null, $updateCalled) implements LiveComponentContract, LifecycleAware { public function __construct( private ComponentId $id, private ?ComponentData $data, private bool &$updateCalled ) { $this->data = $data ?? ComponentData::fromArray(['count' => 0]); } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return $this->data; } public function getRenderData(): RenderData { return new RenderData('test', $this->data->toArray()); } public function increment(): ComponentData { $state = $this->data->toArray(); $state['count']++; return ComponentData::fromArray($state); } public function onMount(): void { } public function onUpdate(): void { $this->updateCalled = true; } public function onDestroy(): void { } }; // Execute action via handler $handler = $this->container->get(LiveComponentHandler::class); $params = ActionParameters::fromArray(['_csrf_token' => $this->generateCsrfToken($component)]); $result = $handler->handle($component, 'increment', $params); expect($updateCalled)->toBeTrue(); expect($result->state->data['count'])->toBe(1); }); it('calls onUpdate() even if action returns void', function () { $updateCalled = false; $component = new class (ComponentId::fromString('test:void'), null, $updateCalled) implements LiveComponentContract, LifecycleAware { public function __construct( private ComponentId $id, private ?ComponentData $data, private bool &$updateCalled ) { $this->data = $data ?? ComponentData::fromArray(['value' => 'initial']); } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return $this->data; } public function getRenderData(): RenderData { return new RenderData('test', $this->data->toArray()); } public function doSomething(): void { // Action that returns void } public function onMount(): void { } public function onUpdate(): void { $this->updateCalled = true; } public function onDestroy(): void { } }; $handler = $this->container->get(LiveComponentHandler::class); $params = ActionParameters::fromArray(['_csrf_token' => $this->generateCsrfToken($component)]); $result = $handler->handle($component, 'doSomething', $params); expect($updateCalled)->toBeTrue(); }); }); describe('Lifecycle Hooks - Optional Implementation', function () { it('does NOT call hooks if component does not implement LifecycleAware', function () { $component = new class (ComponentId::fromString('test:no-hooks')) implements LiveComponentContract { public function __construct( private ComponentId $id ) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray(['value' => 'test']); } public function getRenderData(): RenderData { return new RenderData('test', ['value' => 'test']); } public function doAction(): ComponentData { return $this->getData(); } }; // Should not throw error even without LifecycleAware implementation $handler = $this->container->get(LiveComponentHandler::class); $params = ActionParameters::fromArray(['_csrf_token' => $this->generateCsrfToken($component)]); $result = $handler->handle($component, 'doAction', $params); expect($result)->not->toBeNull(); expect($result->state->data['value'])->toBe('test'); }); }); describe('Lifecycle Hooks - Error Handling', function () { it('catches exceptions in onMount() without failing component creation', function () { $component = new class (ComponentId::fromString('test:mount-error')) implements LiveComponentContract, LifecycleAware { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray(['value' => 'test']); } public function getRenderData(): RenderData { return new RenderData('test', ['value' => 'test']); } public function onMount(): void { throw new \RuntimeException('onMount() error'); } public function onUpdate(): void { } public function onDestroy(): void { } }; $registry = $this->container->get(ComponentRegistry::class); // Should not throw - error is logged but component creation succeeds $resolved = $registry->resolve($component->getId(), null); expect($resolved)->not->toBeNull(); }); it('catches exceptions in onUpdate() without failing action execution', function () { $component = new class (ComponentId::fromString('test:update-error')) implements LiveComponentContract, LifecycleAware { public function __construct(private ComponentId $id) { } public function getId(): ComponentId { return $this->id; } public function getData(): ComponentData { return ComponentData::fromArray(['count' => 0]); } public function getRenderData(): RenderData { return new RenderData('test', $this->getData()->toArray()); } public function increment(): ComponentData { return ComponentData::fromArray(['count' => 1]); } public function onMount(): void { } public function onUpdate(): void { throw new \RuntimeException('onUpdate() error'); } public function onDestroy(): void { } }; $handler = $this->container->get(LiveComponentHandler::class); $params = ActionParameters::fromArray(['_csrf_token' => $this->generateCsrfToken($component)]); // Should not throw - error is logged but action succeeds $result = $handler->handle($component, 'increment', $params); expect($result)->not->toBeNull(); expect($result->state->data['count'])->toBe(1); }); }); describe('Lifecycle Hooks - Timer Component Integration', function () { it('Timer component implements all lifecycle hooks correctly', function () { $timer = new \App\Application\LiveComponents\Timer\TimerComponent( id: ComponentId::fromString('timer:test') ); expect($timer)->toBeInstanceOf(LifecycleAware::class); expect($timer)->toBeInstanceOf(LiveComponentContract::class); // Check getData() returns proper structure $data = $timer->getData(); expect($data->toArray())->toHaveKeys(['seconds', 'isRunning', 'startedAt', 'logs']); }); it('Timer component actions work correctly', function () { $timer = new \App\Application\LiveComponents\Timer\TimerComponent( id: ComponentId::fromString('timer:test') ); $handler = $this->container->get(LiveComponentHandler::class); // Start timer $params = ActionParameters::fromArray(['_csrf_token' => $this->generateCsrfToken($timer)]); $result = $handler->handle($timer, 'start', $params); expect($result->state->data['isRunning'])->toBeTrue(); expect($result->state->data['startedAt'])->not->toBeNull(); // Tick $timer2 = new \App\Application\LiveComponents\Timer\TimerComponent( id: ComponentId::fromString('timer:test'), initialData: ComponentData::fromArray($result->state->data) ); $result2 = $handler->handle($timer2, 'tick', $params); expect($result2->state->data['seconds'])->toBe(1); // Stop $timer3 = new \App\Application\LiveComponents\Timer\TimerComponent( id: ComponentId::fromString('timer:test'), initialData: ComponentData::fromArray($result2->state->data) ); $result3 = $handler->handle($timer3, 'stop', $params); expect($result3->state->data['isRunning'])->toBeFalse(); }); });