diff --git a/tests/src/Kernel/CustomElementBaseRenderTest.php b/tests/src/Kernel/CustomElementBaseRenderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fcf5b6aeddce13e2af16f0a7349b2c127c47a0f1 --- /dev/null +++ b/tests/src/Kernel/CustomElementBaseRenderTest.php @@ -0,0 +1,461 @@ +<?php + +namespace Drupal\Tests\custom_elements\Kernel; + +use Drupal\Core\Render\RendererInterface; +use Drupal\KernelTests\KernelTestBase; +use Drupal\custom_elements\CustomElement; + +/** + * Tests custom elements output generation directly from CustomElement classes. + * + * This is the base test that other 'render tests' can rely on. It contains + * - some assertions on standalone CustomElement objects, though not as + * separate test methods. Since CustomElement is just a value object, this is + * not the most interesting part. (These could be extracted into a separate + * unit test file, if needed.) + * - tests for rendering CustomElement structures into all supported output + * formats. + * + * JSON output needs CustomElementNormalizer, so this tests the combination of + * CustomElement + CustomElementNormalizer classes. + * + * Not all details of CustomElementNormalizer have test coverage yet. + * + * @group custom_elements + */ +class CustomElementBaseRenderTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['custom_elements']; + + /** + * The main custom element instance under test. + * + * @var \Drupal\custom_elements\CustomElement + */ + protected CustomElement $customElement; + + /** + * The custom elements normalizer service. + * + * @var \Drupal\custom_elements\CustomElementNormalizer + */ + protected $normalizer; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->customElement = CustomElement::create('teaser-listing'); + $this->normalizer = $this->container->get('custom_elements.normalizer'); + } + + /* ------------------------------------------------------------------------- + * Test Helpers + * ---------------------------------------------------------------------- */ + + /** + * Renders a custom element to HTML string. + */ + protected function renderCustomElementToString(CustomElement $element): string { + $render_array = $element->toRenderArray(); + return (string) $this->container->get(RendererInterface::class) + ->renderInIsolation($render_array); + } + + /** + * Asserts two HTML strings are equivalent after normalization. + */ + protected function assertHtmlEquals(string $expected, string $actual, string $message = ''): void { + $this->assertSame( + $this->normalizeHtmlWhitespace($expected), + $this->normalizeHtmlWhitespace($actual), + $message ?: 'Rendered HTML should match expected output' + ); + } + + /** + * Normalizes HTML whitespace for consistent comparisons. + */ + protected function normalizeHtmlWhitespace(string $html): string { + $html = preg_replace("/ *\n */m", "", $html); + return preg_replace("/> +</", "><", $html); + } + + /** + * Tests rendering for both Vue versions. + */ + protected function runVueVersionTests(CustomElement $element, array $expected): void { + foreach (['vue-2' => FALSE, 'vue-3' => TRUE] as $version => $isVue3) { + $this->config('custom_elements.settings')->set('markup_style', $version)->save(); + + $output = $this->renderCustomElementToString($element); + $expectedHtml = $isVue3 ? $expected['vue3'] : $expected['vue2']; + + $this->assertHtmlEquals( + $expectedHtml, + $output, + "HTML output mismatch for $version rendering" + ); + } + } + + /* ------------------------------------------------------------------------- + * Test Cases + * ---------------------------------------------------------------------- */ + + /** + * Tests basic element creation with attributes only. + */ + public function testBasicElementWithAttributesWithoutChildrenOrSlots(): void { + // Configure element with attributes. + $this->customElement + ->setAttribute('title', 'Latest news') + ->setAttribute('tags', ['news', 'breaking']); + + // Verify element configuration. + $this->assertEquals( + 'teaser-listing', + $this->customElement->getTag(), + 'Element tag name should match constructor value' + ); + $this->assertEquals( + 'Latest news', + $this->customElement->getAttribute('title'), + 'Title attribute should be stored correctly' + ); + $this->assertEquals( + ['news', 'breaking'], + $this->customElement->getAttribute('tags'), + 'Array attributes should be stored correctly' + ); + $this->assertEmpty( + $this->customElement->getSlots(), + 'New element should not have any slots' + ); + + // Test Vue version rendering differences. + $this->runVueVersionTests($this->customElement, [ + 'vue2' => '<teaser-listing title="Latest news" tags="["news","breaking"]"></teaser-listing>', + 'vue3' => '<teaser-listing title="Latest news" :tags="["news","breaking"]"></teaser-listing>', + ]); + + // Verify JSON normalization. + $normalized = $this->normalizer->normalize($this->customElement); + $this->assertEquals( + [ + 'element' => 'teaser-listing', + 'title' => 'Latest news', + 'tags' => ['news', 'breaking'], + ], + $normalized, + 'Normalized JSON should match expected structure' + ); + } + + /** + * Tests element with a single custom element child in a slot. + */ + public function testSingleSlotWithCustomElementChild(): void { + $child = CustomElement::create('article-teaser') + ->setAttribute('href', 'https://example.com/news/1') + ->setAttribute('excerpt', 'Breaking news'); + + // setSlot() without $index argument explicity marks the slot as having a + // single value. addSlot() or setSlot() with an index do not do this, + // meaning the slot stays an array value. + $random_index = 5; + $element1 = CustomElement::create('teaser-listing') + ->setAttribute('title', 'Latest news') + ->setAttribute('icon', 'news') + ->setSlot('teasers', $child); + $element2 = CustomElement::create('teaser-listing') + ->setAttribute('title', 'Latest news') + ->setAttribute('icon', 'news') + ->setSlot('teasers', $child, 'div', [], $random_index); + $element3 = CustomElement::create('teaser-listing') + ->setAttribute('title', 'Latest news') + ->setAttribute('icon', 'news') + ->addSlot('teasers', $child); + + // Verify slot configuration. + foreach ([$element1, $element2, $element3] as $index => $element) { + // Quickly hardcoded: only $element2 has index 5 populated. + $slot_index = $index == 1 ? $random_index : 0; + $slots = $element->getSlots(); + $this->assertArrayHasKey( + 'teasers', + $slots, + 'Element should have slot with specified name' + ); + $this->assertCount( + 1, + $slots['teasers'], + 'Slot should contain exactly one entry' + ); + $this->assertInstanceOf( + CustomElement::class, + $slots['teasers'][$slot_index]['content'], + 'Slot content should be a CustomElement instance' + ); + $this->assertEquals( + 'Breaking news', + $slots['teasers'][$slot_index]['content']->getAttribute('excerpt'), + 'Child element should maintain configured attributes' + ); + } + + // Markup output does not differ for 'single value' slots. + $expected = [ + 'vue2' => '<teaser-listing title="Latest news" icon="news"> + <article-teaser href="https://example.com/news/1" excerpt="Breaking news" slot="teasers"></article-teaser> + </teaser-listing>', + 'vue3' => '<teaser-listing title="Latest news" icon="news"> + <template #teasers> + <article-teaser href="https://example.com/news/1" excerpt="Breaking news" slot="teasers"></article-teaser> + </template> + </teaser-listing>', + ]; + $this->runVueVersionTests($element1, $expected); + $this->runVueVersionTests($element2, $expected); + $this->runVueVersionTests($element3, $expected); + + // JSON output differs for 'single value' slots. + $singleValueTeaser = [ + 'element' => 'teaser-listing', + 'title' => 'Latest news', + 'icon' => 'news', + 'teasers' => [ + 'element' => 'article-teaser', + 'href' => 'https://example.com/news/1', + 'excerpt' => 'Breaking news', + ], + ]; + $arrayTeaser = [ + 'element' => 'teaser-listing', + 'title' => 'Latest news', + 'icon' => 'news', + 'teasers' => [ + [ + 'element' => 'article-teaser', + 'href' => 'https://example.com/news/1', + 'excerpt' => 'Breaking news', + ], + ], + ]; + $this->assertEquals($singleValueTeaser, $this->normalizer->normalize($element1), 'Normalized JSON should preserve nested element structure'); + $this->assertEquals($arrayTeaser, $this->normalizer->normalize($element2), 'Normalized JSON should preserve nested element structure'); + $this->assertEquals($arrayTeaser, $this->normalizer->normalize($element3), 'Normalized JSON should preserve nested element structure'); + } + + /** + * Tests element with raw HTML content in a slot. + */ + public function testSlotWithHtmlContent(): void { + $markup = '<p>Introduction content</p>'; + + // Test addSlot() vs setSlot() with similar setup as above. + $random_index = 5; + $element1 = CustomElement::create('teaser-listing') + ->setAttribute('title', 'Latest news') + ->setAttribute('icon', 'news') + ->setSlot('introduction', $markup); + $element2 = CustomElement::create('teaser-listing') + ->setAttribute('title', 'Latest news') + ->setAttribute('icon', 'news') + ->setSlot('introduction', $markup, 'div', [], $random_index); + $element3 = CustomElement::create('teaser-listing') + ->setAttribute('title', 'Latest news') + ->setAttribute('icon', 'news') + ->addSlot('introduction', $markup); + + // Verify slot configuration. + foreach ([$element1, $element2, $element3] as $index => $element) { + // Quickly hardcoded: only $element2 has index 5 populated. + $slot_index = $index == 1 ? $random_index : 0; + $slots = $element->getSlots(); + $this->assertArrayHasKey( + 'introduction', + $slots, + 'Element should have slot for HTML content' + ); + $this->assertCount( + 1, + $slots['introduction'], + 'HTML slot should contain single entry' + ); + $this->assertEquals( + $markup, + (string) $slots['introduction'][$slot_index]['content'], + 'Slot content should preserve raw HTML' + ); + } + + // Markup output does not differ for 'single value' slots. + $expected = [ + 'vue2' => '<teaser-listing title="Latest news" icon="news">' + . '<div slot="introduction"><p>Introduction content</p></div>' + . '</teaser-listing>', + 'vue3' => '<teaser-listing title="Latest news" icon="news">' + . '<template #introduction><p>Introduction content</p></template>' + . '</teaser-listing>', + ]; + $this->runVueVersionTests($element1, $expected); + $this->runVueVersionTests($element2, $expected); + $this->runVueVersionTests($element3, $expected); + + // JSON output differs for 'single value' slots. + $singleValueIntro = [ + 'element' => 'teaser-listing', + 'title' => 'Latest news', + 'icon' => 'news', + 'introduction' => $markup, + ]; + $arrayIntro = [ + 'element' => 'teaser-listing', + 'title' => 'Latest news', + 'icon' => 'news', + 'introduction' => [$markup], + ]; + $this->assertEquals($singleValueIntro, $this->normalizer->normalize($element1), 'Normalized JSON should include raw HTML content'); + $this->assertEquals($arrayIntro, $this->normalizer->normalize($element2), 'Normalized JSON should include raw HTML content'); + $this->assertEquals($arrayIntro, $this->normalizer->normalize($element3), 'Normalized JSON should include raw HTML content'); + } + + /** + * Tests slot operations with different method call orders. + */ + public function testSlotOperationsWithMultipleChildren(): void { + // Create test elements. + $child1 = CustomElement::create('test-element')->setAttribute('id', '1'); + $child2 = CustomElement::create('test-element')->setAttribute('id', '2'); + $child3 = CustomElement::create('test-element')->setAttribute('id', '3'); + + $element1 = CustomElement::create('test-container'); + $element1->addSlot('main', $child1) + ->addSlot('main', $child2) + ->addSlot('main', $child3); + // Test setSlot() without $index, combined with other addSlot() / setSlot() + // calls; see behavior below. + $element2 = CustomElement::create('test-container'); + $element2->setSlot('main', $child1) + ->addSlot('main', $child2) + ->addSlot('main', $child3); + + // Verify slot content counts. + $this->assertCount(3, $element1->getSlots()['main'], 'setSlot sequence should have 3 elements'); + $this->assertCount(3, $element2->getSlots()['main'], 'addSlot sequence should have 3 elements'); + + // Markup output does not differ for 'single value' slots. + $expected = [ + 'vue2' => '<test-container> + <test-element id="1" slot="main"></test-element> + <test-element id="2" slot="main"></test-element> + <test-element id="3" slot="main"></test-element> +</test-container>', + 'vue3' => '<test-container> + <template #main> + <test-element id="1" slot="main"></test-element> + <test-element id="2" slot="main"></test-element> + <test-element id="3" slot="main"></test-element> + </template> +</test-container>', + ]; + $this->runVueVersionTests($element1, $expected); + + // JSON output contains only the 'single value' slot; all slots added (with + // the same slot name) after the setSlot() are ignored. + $singleSlotValue = [ + 'element' => 'test-container', + 'main' => ['element' => 'test-element', 'id' => '1'], + ]; + $allSlotValues = [ + 'element' => 'test-container', + 'main' => [ + ['element' => 'test-element', 'id' => '1'], + ['element' => 'test-element', 'id' => '2'], + ['element' => 'test-element', 'id' => '3'], + ], + ]; + $this->assertEquals($allSlotValues, $this->normalizer->normalize($element1), 'JSON mismatch'); + $this->assertEquals($singleSlotValue, $this->normalizer->normalize($element2), 'JSON mismatch with expected single-value slot output'); + } + + /** + * Tests complex element with multiple slots containing various children. + */ + public function testMultipleSlotsWithVariousChildren(): void { + // Create test elements. + $teasers = [ + CustomElement::create('article-teaser') + ->setAttribute('href', 'https://example.com/news/1') + ->setAttribute('excerpt', 'Top story'), + CustomElement::create('article-teaser') + ->setAttribute('href', 'https://example.com/news/2') + ->setAttribute('excerpt', 'Secondary story'), + ]; + + $footer = CustomElement::create('content-footer') + ->setAttribute('text', 'More news'); + + // Configure parent element with multiple slots. + $this->customElement->setAttribute('title', 'Latest news') + ->setAttribute('icon', 'news') + ->addSlot('teasers', $teasers[0]) + ->addSlot('teasers', $teasers[1]) + ->setSlot('footer', $footer); + + // Verify slot configuration. + $slots = $this->customElement->getSlots(); + $this->assertCount( + 2, + $slots['teasers'], + 'Main content slot should contain all teasers' + ); + $this->assertCount( + 1, + $slots['footer'], + 'Footer slot should contain single element' + ); + + // Test Vue version rendering differences. + $this->runVueVersionTests($this->customElement, [ + 'vue2' => '<teaser-listing title="Latest news" icon="news"> + <article-teaser href="https://example.com/news/1" excerpt="Top story" slot="teasers"></article-teaser> + <article-teaser href="https://example.com/news/2" excerpt="Secondary story" slot="teasers"></article-teaser> + <content-footer text="More news" slot="footer"></content-footer> + </teaser-listing>', + 'vue3' => '<teaser-listing title="Latest news" icon="news"> + <template #teasers> + <article-teaser href="https://example.com/news/1" excerpt="Top story" slot="teasers"></article-teaser> + <article-teaser href="https://example.com/news/2" excerpt="Secondary story" slot="teasers"></article-teaser> + </template> + <template #footer> + <content-footer text="More news" slot="footer"></content-footer> + </template> + </teaser-listing>', + ]); + + // Verify JSON normalization. + $normalized = $this->normalizer->normalize($this->customElement); + $this->assertEquals( + [ + 'element' => 'teaser-listing', + 'title' => 'Latest news', + 'icon' => 'news', + 'teasers' => [ + ['element' => 'article-teaser', 'href' => 'https://example.com/news/1', 'excerpt' => 'Top story'], + ['element' => 'article-teaser', 'href' => 'https://example.com/news/2', 'excerpt' => 'Secondary story'], + ], + 'footer' => ['element' => 'content-footer', 'text' => 'More news'], + ], + $normalized, + 'Normalized JSON should maintain separate slot structures' + ); + } + +}