Files
michaelschiemer/tests/Feature/Framework/LiveComponents/TestHarness/LiveComponentTestCase.php
2025-11-24 21:28:25 +01:00

335 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Framework\LiveComponents\TestHarness;
use App\Framework\DI\Container;
use App\Framework\LiveComponents\ComponentRegistry;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\LiveComponentHandler;
use App\Framework\LiveComponents\Rendering\FragmentRenderer;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
use App\Framework\View\LiveComponentRenderer;
use PHPUnit\Framework\TestCase;
/**
* Base Test Case for LiveComponent Tests
*
* Provides fluent API for testing LiveComponents:
* - mount() - Mount component with initial state
* - call() - Call action on component
* - seeHtmlHas() - Assert HTML contains content
* - seeStateEquals() - Assert state matches expected
* - seeStateKey() - Assert specific state key
* - seeEventDispatched() - Assert event was dispatched
* - seeFragment() - Assert fragment contains content
*
* Usage:
* ```php
* class MyComponentTest extends LiveComponentTestCase
* {
* public function test_increments_counter(): void
* {
* $this->mount('counter:test', ['count' => 5])
* ->call('increment')
* ->seeStateKey('count', 6)
* ->seeHtmlHas('Count: 6');
* }
* }
* ```
*/
abstract class LiveComponentTestCase extends TestCase
{
protected ComponentRegistry $registry;
protected LiveComponentHandler $handler;
protected LiveComponentRenderer $renderer;
protected FragmentRenderer $fragmentRenderer;
protected ?LiveComponentContract $currentComponent = null;
protected ?ComponentUpdate $lastUpdate = null;
protected string|array $lastHtml = '';
protected array $lastState = [];
protected array $lastEvents = [];
protected function setUp(): void
{
parent::setUp();
$container = $this->getContainer();
$this->registry = $container->get(ComponentRegistry::class);
$this->handler = $container->get(LiveComponentHandler::class);
$this->renderer = $container->get(LiveComponentRenderer::class);
$this->fragmentRenderer = $container->get(FragmentRenderer::class);
}
/**
* Get DI Container instance
*/
protected function getContainer(): Container
{
static $container = null;
if ($container === null) {
// Bootstrap container for tests
require_once __DIR__ . '/../../../../bootstrap.php';
$container = \createTestContainer();
}
return $container;
}
/**
* Mount a component with initial state
*
* @param string $componentId Component ID (e.g., 'counter:test')
* @param array $initialData Initial component data
* @return $this
*/
protected function mount(string $componentId, array $initialData = []): self
{
$this->currentComponent = $this->registry->resolve(
ComponentId::fromString($componentId),
$initialData
);
$this->lastHtml = $this->renderer->render($this->currentComponent);
$this->lastState = $this->currentComponent->state->toArray();
$this->lastEvents = [];
$this->lastUpdate = null;
return $this;
}
/**
* Call an action on the current component
*
* @param string $action Action name
* @param array $params Action parameters
* @param array $fragments Optional fragment names to extract
* @return $this
*/
protected function call(string $action, array $params = [], array $fragments = []): self
{
if ($this->currentComponent === null) {
throw new \RuntimeException('No component mounted. Call mount() first.');
}
// Create ActionParameters with CSRF token
$actionParams = ActionParameters::fromArray($params);
// Add CSRF token if not present
if (!$actionParams->hasCsrfToken()) {
$csrfToken = $this->registry->generateCsrfToken($this->currentComponent->id);
$actionParams = ActionParameters::fromArray(array_merge($params, [
'csrf_token' => $csrfToken->toString()
]));
}
// Execute action
$this->lastUpdate = $this->handler->handle(
$this->currentComponent,
$action,
$actionParams
);
// Resolve component with updated state
$this->currentComponent = $this->registry->resolve(
$this->currentComponent->id,
$this->lastUpdate->state->data
);
// Render updated component
if (!empty($fragments)) {
$fragmentCollection = $this->fragmentRenderer->renderFragments(
$this->currentComponent,
$fragments
);
$this->lastHtml = [];
foreach ($fragmentCollection as $fragment) {
$this->lastHtml[$fragment->name] = $fragment->content;
}
} else {
$this->lastHtml = $this->renderer->render($this->currentComponent);
}
$this->lastState = $this->currentComponent->state->toArray();
$this->lastEvents = $this->lastUpdate->events;
return $this;
}
/**
* Assert HTML contains the given content
*
* @param string $needle Content to search for
* @return $this
*/
protected function seeHtmlHas(string $needle): self
{
$html = is_array($this->lastHtml) ? implode('', $this->lastHtml) : $this->lastHtml;
$this->assertStringContainsString($needle, $html, "Expected HTML to contain '{$needle}'");
return $this;
}
/**
* Assert HTML does not contain the given content
*
* @param string $needle Content to search for
* @return $this
*/
protected function seeHtmlNotHas(string $needle): self
{
$html = is_array($this->lastHtml) ? implode('', $this->lastHtml) : $this->lastHtml;
$this->assertStringNotContainsString($needle, $html, "Expected HTML not to contain '{$needle}'");
return $this;
}
/**
* Assert state equals expected state
*
* @param array $expectedState Expected state array
* @return $this
*/
protected function seeStateEquals(array $expectedState): self
{
$this->assertEquals($expectedState, $this->lastState, 'State does not match expected');
return $this;
}
/**
* Assert specific state key has expected value
*
* @param string $key State key
* @param mixed $value Expected value
* @return $this
*/
protected function seeStateKey(string $key, mixed $value): self
{
$this->assertArrayHasKey($key, $this->lastState, "State key '{$key}' not found");
$this->assertEquals($value, $this->lastState[$key], "State key '{$key}' does not match expected value");
return $this;
}
/**
* Assert state key exists
*
* @param string $key State key
* @return $this
*/
protected function seeStateHasKey(string $key): self
{
$this->assertArrayHasKey($key, $this->lastState, "State key '{$key}' not found");
return $this;
}
/**
* Assert event was dispatched
*
* @param string $eventName Event name
* @param array|null $expectedPayload Optional expected payload
* @return $this
*/
protected function seeEventDispatched(string $eventName, ?array $expectedPayload = null): self
{
$found = false;
foreach ($this->lastEvents as $event) {
if ($event->name === $eventName) {
$found = true;
if ($expectedPayload !== null) {
$actualPayload = $event->payload->toArray();
$this->assertEquals($expectedPayload, $actualPayload, "Event '{$eventName}' payload does not match");
}
break;
}
}
$this->assertTrue($found, "Event '{$eventName}' was not dispatched");
return $this;
}
/**
* Assert event was not dispatched
*
* @param string $eventName Event name
* @return $this
*/
protected function seeEventNotDispatched(string $eventName): self
{
foreach ($this->lastEvents as $event) {
if ($event->name === $eventName) {
$this->fail("Event '{$eventName}' was dispatched but should not have been");
}
}
return $this;
}
/**
* Assert fragment contains content
*
* @param string $fragmentName Fragment name
* @param string $content Expected content
* @return $this
*/
protected function seeFragment(string $fragmentName, string $content): self
{
if (!is_array($this->lastHtml)) {
$this->fail('Fragments not requested. Use call() with fragments parameter.');
}
$this->assertArrayHasKey($fragmentName, $this->lastHtml, "Fragment '{$fragmentName}' not found");
$this->assertStringContainsString($content, $this->lastHtml[$fragmentName], "Fragment '{$fragmentName}' does not contain expected content");
return $this;
}
/**
* Assert component instance
*
* @param string $expectedClass Expected component class
* @return $this
*/
protected function assertComponent(string $expectedClass): self
{
$this->assertNotNull($this->currentComponent, 'No component mounted');
$this->assertInstanceOf($expectedClass, $this->currentComponent);
return $this;
}
/**
* Get current component instance
*/
protected function getComponent(): ?LiveComponentContract
{
return $this->currentComponent;
}
/**
* Get current HTML
*/
protected function getHtml(): string|array
{
return $this->lastHtml;
}
/**
* Get current state
*/
protected function getState(): array
{
return $this->lastState;
}
/**
* Get last dispatched events
*/
protected function getEvents(): array
{
return $this->lastEvents;
}
}