custom_elements-8.x-2.x-dev/tests/src/Kernel/CustomElementBaseRenderTest.php
tests/src/Kernel/CustomElementBaseRenderTest.php
<?php
namespace Drupal\Tests\custom_elements\Kernel;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Render\RendererInterface;
use Drupal\KernelTests\KernelTestBase;
use Drupal\custom_elements\CustomElement;
use Drupal\Tests\custom_elements\Traits\TestHelperTrait;
/**
* 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 {
use TestHelperTrait;
/**
* {@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->installConfig(['custom_elements']);
// These tests expect legacy format.
\Drupal::configFactory()
->getEditable('custom_elements.settings')
->set('json_format', 'legacy')
->save();
$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'
);
}
/**
* Tests the toArray() helper method.
*/
public function testToArrayHelper() {
// Create a custom element with slots and attributes.
$element = CustomElement::create('test-component');
$element->setAttribute('id', 'test-123');
$element->setAttribute('data_role', 'main');
$element->setSlot('header', 'Header Content');
$element->setSlot('body', 'Body Content');
// Test default behavior (camelCase conversion).
$array = $element->toArray();
$this->assertIsArray($array);
$this->assertEquals('test-component', $array['element']);
$this->assertEquals('test-123', $array['id']);
// Check that underscore is converted to camelCase.
$this->assertEquals('main', $array['dataRole']);
$this->assertArrayHasKey('header', $array);
$this->assertArrayHasKey('body', $array);
// Test with preserve_keys = TRUE.
$array_preserved = $element->toArray(TRUE);
$this->assertIsArray($array_preserved);
$this->assertEquals('test-component', $array_preserved['element']);
// Check that underscore was converted to dash in attribute name
// (this happens during setAttribute).
$this->assertEquals('main', $array_preserved['data-role']);
// Test with cache metadata collection.
$cache_metadata = new BubbleableMetadata();
$element->addCacheTags(['custom_element:test']);
$array_with_cache = $element->toArray(FALSE, $cache_metadata);
$this->assertIsArray($array_with_cache);
// Verify cache metadata was collected.
$this->assertContains('custom_element:test', $cache_metadata->getCacheTags());
}
/**
* Tests the toMarkup() helper method.
*/
public function testToMarkupHelper() {
// Create a custom element with content.
$element = CustomElement::create('my-card');
$element->setAttribute('class', 'featured');
$element->setSlot('title', 'Test Title');
$element->setSlot('content', 'Test content goes here');
// Get the markup using the helper.
$markup = $element->toMarkup();
// Verify it returns a MarkupInterface object.
$this->assertInstanceOf('\Drupal\Component\Render\MarkupInterface', $markup);
// Test the actual markup output.
$expected = '<my-card class="featured">
<div slot="title">Test Title</div>
<div slot="content">Test content goes here</div>
</my-card>';
$this->assertMarkupEquals($expected, (string) $markup, 'Basic element markup matches expected structure');
// Test with nested custom elements.
$parent = CustomElement::create('parent-element');
$child = CustomElement::create('child-element');
$child->setSlot('default', 'Child content');
$parent->setSlotFromCustomElement('slot-name', $child);
$parent_markup = $parent->toMarkup();
// Verify nested structure.
// Note: default slot content is rendered directly without wrapper.
$expected_nested = '<parent-element>
<child-element slot="slot-name">
Child content
</child-element>
</parent-element>';
$this->assertMarkupEquals($expected_nested, (string) $parent_markup, 'Nested element markup matches expected structure');
}
}
