Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/AttrWriterTest.php
T
janniclas.loosenandClaude Opus 5.5 7762b76305 feat: Divi save pipeline – tree_rev lock, kses-safe save, revision, flags, cache, ToolResult
DiviSaver/DiviWriter/DiviComposition/DiviChange, WpValueSanitizer (S4),
WpMediaResolver, DiviFlags (S11), DynamicContentMap from Divi's live
registry (S22), S31 wiring, S35 control strip at apply() entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 14:56:47 +02:00

515 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
use PHPUnit\Framework\TestCase;
final class AttrWriterTest extends TestCase
{
private const HEADING = [
'title' => [
'innerContent' => ['desktop' => ['value' => 'Alt']],
'decoration' => ['font' => ['font' => [
'desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111']],
'phone' => ['value' => ['size' => '22px']],
]]],
],
'builderVersion' => '5.2.1',
];
private const TEXT = [
'content' => ['innerContent' => ['desktop' => ['value' => '<p>Alt</p>']]],
'builderVersion' => '5.2.1',
];
public function testMergesShortAndRawBreakpointsAndKeepsTheRest(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [
'title.decoration.font.font' => [
'desktop' => ['value' => ['headingLevel' => 'h3'], 'hover' => ['color' => '{{color:primary}}']],
'tablet' => ['size' => '26px'],
],
'title.innerContent' => ['desktop' => '<b>Neu</b>'],
], 'attrs');
self::assertSame([], $result['errors']);
self::assertSame(
[
'desktop' => [
'value' => ['headingLevel' => 'h3'],
'hover' => ['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'],
],
'phone' => ['value' => ['size' => '22px']],
'tablet' => ['value' => ['size' => '26px']],
],
$result['attrs']['title']['decoration']['font']['font']
);
// Heading title is kind "text" in the catalog (ruling S3): tags are stripped.
self::assertSame('Neu', $result['attrs']['title']['innerContent']['desktop']['value']);
self::assertSame('5.2.1', $result['attrs']['builderVersion']);
}
public function testNullRemovesPathsAndStatesAndTopLevelHoverMeansDesktop(): void
{
$writer = ComposeFixture::attrWriter();
$removed = $writer->apply('divi/heading', self::HEADING, ['title.decoration.font.font' => null], 'attrs');
self::assertArrayNotHasKey('decoration', $removed['attrs']['title']);
$phoneGone = $writer->apply('divi/heading', self::HEADING, [
'title.decoration.font.font' => ['phone' => ['value' => null], 'hover' => ['color' => '#000000']],
], 'attrs');
self::assertSame(
['desktop' => ['value' => ['headingLevel' => 'h2', 'color' => '#111111'], 'hover' => ['color' => '#000000']]],
$phoneGone['attrs']['title']['decoration']['font']['font']
);
}
public function testUnknownPathsGetSuggestions(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/heading', self::HEADING, [
'title.decoration.font' => ['desktop' => ['color' => '#fff']],
'title.innerContent' => ['mobile' => 'x'],
], 'changes[0].attrs');
self::assertSame(['title.decoration.font'], $result['invalid_paths']);
self::assertStringContainsString(
'title.decoration.font.font',
$result['errors']['changes[0].attrs.title.decoration.font']
);
self::assertStringContainsString('unbekannter Breakpoint "mobile"', $result['errors']['changes[0].attrs.title.innerContent']);
self::assertSame(self::HEADING, $result['attrs']);
}
public function testSanitizesByPlace(): void
{
$writer = ComposeFixture::attrWriter();
self::assertSame('', $writer->cleanValue('divi/button', 'button.innerContent', 'javascript:alert(1)', 'linkUrl'));
self::assertSame('<p>x</p>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p><script>y</script>'));
self::assertSame('<script>ok()</script>', $writer->cleanValue('divi/code', 'content.innerContent', '<script>ok()</script>'));
self::assertSame('10px', $writer->cleanValue('divi/text', 'module.decoration.spacing', '<b>10px</b>'));
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
$writer->cleanValue('divi/text', 'module.decoration.background', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$')
);
self::assertSame(['desktop' => ['value' => 'x']], AttrWriter::normalize(['desktop' => 'x']));
}
/** Ruling S3/S3a: kind from the catalog, per sub-field for composite paths, else key name, else text. */
public function testRoutesByCatalogKindAndKeyNameAtEveryDepth(): void
{
$writer = ComposeFixture::attrWriter();
// Unknown kind, URL keys deep inside the value.
self::assertSame(
['color' => '#fff', 'image' => ['url' => '', 'parallax' => ['enabled' => 'on']]],
$writer->cleanValue('divi/text', 'module.decoration.background', [
'color' => '<i>#fff</i>',
'image' => ['url' => 'javascript:alert(1)', 'parallax' => ['enabled' => 'on']],
])
);
self::assertSame(
['url' => '', 'target' => 'on'],
$writer->cleanValue('divi/text', 'module.advanced.link', ['url' => ' JavaScript:alert(1)', 'target' => '<b>on</b>'])
);
self::assertSame(['href' => '', 'src' => '', 'image' => ''], $writer->cleanValue('divi/text', 'module.advanced.link', [
'href' => 'javascript:a()', 'src' => 'javascript:b()', 'image' => 'javascript:c()',
]));
// Rich text keeps markup, text kind strips it, unknown innerContent is text too.
self::assertSame('<p>Hallo <b>Welt</b></p>', $writer->cleanValue('divi/text', 'content.innerContent', '<p>Hallo <b>Welt</b></p>'));
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '<h1>Titel</h1>'));
self::assertSame('Los', $writer->cleanValue('divi/button', 'button.innerContent', '<b>Los</b>', 'text'));
// Composite path with per-subName kinds (buttonOne.innerContent: text → text, linkUrl → url).
self::assertSame(
['text' => 'Mehr', 'linkUrl' => ''],
$writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', [
'text' => '<b>Mehr</b>',
'linkUrl' => 'javascript:alert(1)',
])
);
self::assertSame('', $writer->cleanValue('divi/fullwidth-header', 'buttonOne.innerContent', 'javascript:x', 'linkUrl'));
// An explicit sub-kind wins over the key name; the image module's alt is text, src a URL.
self::assertSame(
['useIcon' => 'on', 'src' => ''],
$writer->cleanValue('divi/blurb', 'imageIcon.innerContent', ['useIcon' => 'on', 'src' => 'javascript:x'])
);
self::assertSame(
['src' => 'https://kunde.test/a.jpg', 'alt' => 'Team'],
$writer->cleanValue('divi/image', 'image.innerContent', ['src' => 'https://kunde.test/a.jpg', 'alt' => '<b>Team</b>'])
);
// Numbers and booleans are no strings: unchanged.
self::assertSame(['size' => 12, 'on' => true], $writer->cleanValue('divi/text', 'module.decoration.spacing', ['size' => 12, 'on' => true]));
}
/**
* Ruling S6: each malicious reference fails and nothing is stored (as an editor without
* unfiltered_html).
*
* @return array<string, array{string, string, array<string, mixed>, array<string, mixed>}>
*/
public static function malicious(): array
{
$cases = [];
foreach (VariableRefTest::malicious() as $name => [$value]) {
$cases[$name] = ['divi/text', 'content.innerContent', ['desktop' => $value], self::TEXT];
}
$cases['embedded reference in rich text'] = [
'divi/text',
'content.innerContent',
['desktop' => '<p>Hallo $variable({"type":"content","value":{"name":"post_title","settings":{}}})$</p>'],
self::TEXT,
];
$cases['embedded reference deep in a value'] = [
'divi/text',
'module.decoration.background',
['desktop' => ['image' => ['url' => 'https://a.test/$variable({"type":"content","value":{"name":"post_title","settings":{}}})$']]],
self::TEXT,
];
// S28: markers a real sanitizer rebuilds after the raw check (verified in DDEV).
$reference = '({"type":"content","value":{"name":"post_title","settings":{"before":"<script>alert(1)</script>","after":""}}})$';
$cases['tag-split marker in rich text'] = [
'divi/text', 'content.innerContent', ['desktop' => '$variable<x>' . $reference], self::TEXT,
];
$cases['octet-split marker in a text title'] = [
'divi/heading', 'title.innerContent', ['desktop' => '$variable%41' . $reference], self::HEADING,
];
$cases['tag-split marker deep in a value'] = [
'divi/text', 'module.decoration.background', ['desktop' => ['color' => '$variable<x>' . $reference]], self::TEXT,
];
$cases['sanitizer-built valid reference'] = [
'divi/text',
'content.innerContent',
['desktop' => '$variable<x>({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
self::TEXT,
];
$cases['javascript via a reference in a URL field'] = [
'divi/fullwidth-header',
'buttonOne.innerContent',
['desktop' => [
'text' => 'Los',
'linkUrl' => '$variable({"type":"content","value":{"name":"post_link_url","settings":{"before":"javascript:alert(1)//","after":""}}})$',
]],
[],
];
$cases['javascript via a reference in a URL key'] = [
'divi/text',
'module.advanced.link',
['desktop' => ['url' => '$variable({"type":"content","value":{"name":"custom_meta_ziel","settings":{"before":"javascript:","after":""}}})$']],
self::TEXT,
];
return $cases;
}
/**
* @dataProvider malicious
* @param array<string, mixed> $map
* @param array<string, mixed> $attrs
*/
public function testRejectsMaliciousReferencesAndStoresNothing(string $module, string $path, array $map, array $attrs): void
{
$result = ComposeFixture::attrWriter()->apply($module, $attrs, [$path => $map], 'attrs');
self::assertArrayHasKey('attrs.' . $path, $result['errors']);
self::assertSame($attrs, $result['attrs']);
}
/** S28: the split-marker rows carry no marker before sanitizing, but the sanitizer rebuilds one. */
public function testSplitMarkersAreRebuiltBySanitizing(): void
{
$sanitizer = ComposeFixture::sanitizer();
$rows = ['tag-split marker in rich text', 'octet-split marker in a text title',
'tag-split marker deep in a value'];
foreach (array_intersect_key(self::malicious(), array_flip($rows)) as $name => [, , $map]) {
$raw = (string) json_encode($map, JSON_UNESCAPED_SLASHES);
$value = is_array($map['desktop']) ? $map['desktop']['color'] : $map['desktop'];
self::assertStringNotContainsString('$variable(', $value, $name);
self::assertStringContainsString('$variable(', $sanitizer->text($value) . $sanitizer->html($value), $name . ' ' . $raw);
}
}
/** Ruling S21: an editor's script in before/after is cleaned out, the reference re-serialized. */
public function testReferenceSettingsAreCleanedForEditors(): void
{
$sent = '$variable({"type":"content","value":{"name":"post_title","settings":{"after":"","before":"<script>alert(1)</script>Titel: "}}})$';
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['content.innerContent' => ['desktop' => $sent]], 'attrs');
self::assertSame([], $result['errors']);
self::assertSame(
'$variable({"type":"content","value":{"name":"post_title","settings":{"after":"","before":"Titel: "}}})$',
$result['attrs']['content']['innerContent']['desktop']['value']
);
}
public function testEmbeddedReferenceNamesTheRule(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Verweise nur allein im Wert');
ComposeFixture::attrWriter()->cleanValue('divi/text', 'content.innerContent', 'a $variable({}) b');
}
/** Ruling S7: Divi's own HTML/attribute fields only for users with unfiltered_html. */
public function testHtmlAndAttributeFieldsNeedUnfilteredHtml(): void
{
$changes = [
'module.decoration.attributes' => ['desktop' => ['attributes' => [['name' => 'data-x', 'value' => '1']]]],
'module.advanced.html' => ['desktop' => ['elementType' => 'section']],
];
$editor = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, $changes, 'attrs');
self::assertSame(self::TEXT, $editor['attrs']);
self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.decoration.attributes']);
self::assertStringContainsString('nur im Divi-Builder', $editor['errors']['attrs.module.advanced.html']);
$removal = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, ['module.advanced.html' => null], 'attrs');
self::assertArrayHasKey('attrs.module.advanced.html', $removal['errors']);
$admin = ComposeFixture::attrWriter(true)->apply('divi/text', self::TEXT, $changes, 'attrs');
self::assertSame([], $admin['errors']);
self::assertSame('section', $admin['attrs']['module']['advanced']['html']['desktop']['value']['elementType']);
self::assertSame('data-x', $admin['attrs']['module']['decoration']['attributes']['desktop']['value']['attributes'][0]['name']);
// Deeper paths and the canvas portal's custom CSS are gated too (fix round 1, M-2).
foreach (['module.advanced.html.htmlBefore', 'module.decoration.attributes.desktop', 'css.advanced.customCss'] as $path) {
try {
ComposeFixture::attrWriter()->cleanValue('divi/canvas-portal', $path, 'x');
self::fail('Accepted for an editor: ' . $path);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
}
}
self::assertSame('x', ComposeFixture::attrWriter(true)->cleanValue('divi/canvas-portal', 'css.advanced.customCss', 'x'));
self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.advanced.htmlish', 'x'));
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('nur im Divi-Builder');
ComposeFixture::attrWriter()->cleanValue('divi/button', 'module.decoration.attributes', ['attributes' => []]);
}
/** Ruling S26: every free-form custom CSS path (css.*) is builder-only for editors, by path shape. */
public function testCustomCssPathsNeedUnfilteredHtml(): void
{
$css = ['mainElement' => 'color:red;} body{display:none'];
foreach (['css', 'css.desktop', 'css.advanced.customCss', 'css.advanced.mainElement', 'title.css'] as $path) {
try {
ComposeFixture::attrWriter()->cleanValue('divi/text', $path, $css);
self::fail('Accepted for an editor: ' . $path);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('nur im Divi-Builder', $e->getMessage());
}
self::assertSame($css, ComposeFixture::attrWriter(true)->cleanValue('divi/text', $path, $css));
}
// Only a whole "css" segment counts.
self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.advanced.cssClass', 'x'));
self::assertSame('x', ComposeFixture::attrWriter()->cleanValue('divi/text', 'module.cssish', 'x'));
}
/** Ruling S23: content.innerContent of unknown kind (defined only in Divi's JS) is rich text. */
public function testUnknownContentInnerContentIsRichText(): void
{
self::assertSame('unknown', ModuleCatalogTest::sampleCatalog()->pathKind('divi/blurb', 'content.innerContent'));
self::assertSame('<p>Hallo</p>', ComposeFixture::attrWriter()->cleanValue('divi/blurb', 'content.innerContent', '<p>Hallo</p><script>x()</script>'));
// Other unknown innerContent stays text.
self::assertSame('Los', ComposeFixture::attrWriter()->cleanValue('divi/button', 'button.innerContent', '<b>Los</b>'));
}
/** Ruling S24: keys below a scalar-kind path are unknown; a …Url path segment means URL. */
public function testKeysBelowScalarKindsAndUrlPathSegments(): void
{
$writer = ComposeFixture::attrWriter();
self::assertSame(
['url' => '', 'text' => 'a'],
$writer->cleanValue('divi/heading', 'title.innerContent', ['url' => 'javascript:x', 'text' => '<b>a</b>'])
);
self::assertSame('', $writer->cleanValue('divi/contact-form', 'success.advanced.redirectUrl', 'javascript:alert(1)'));
self::assertSame('', $writer->cleanValue('divi/text', 'module.advanced.imageUrl', 'javascript:alert(1)'));
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', '<b>Titel</b>'));
}
/** Ruling S13: keys inside value maps are plain identifiers. */
public function testRejectsOddKeysInsideValues(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
'module.decoration.spacing' => ['desktop' => ['<img src=x onerror=1>' => '1px']],
], 'attrs');
self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']);
self::assertSame(self::TEXT, $result['attrs']);
}
public function testRejectsInvalidUtf8(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
'content.innerContent' => ['desktop' => "<p>\xC3\x28</p>"],
], 'attrs');
self::assertStringContainsString('UTF-8', $result['errors']['attrs.content.innerContent']);
}
public function testReferencesFromAnEarlierReadAreReserialized(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
'module.decoration.background' => ['desktop' => [
'color' => '$variable({"value":{"name":"gcid-a1b2","settings":[]},"type":"color"})$',
]],
], 'attrs');
self::assertSame([], $result['errors']);
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$',
$result['attrs']['module']['decoration']['background']['desktop']['value']['color']
);
}
/** Ruling S32: C0 controls (except tab, LF, CR) and DEL go at the choke point, before the marker check. */
public function testControlCharactersAreStrippedBeforeTheMarkerCheck(): void
{
$writer = ComposeFixture::attrWriter();
self::assertSame("a\tb\n", $writer->cleanValue('divi/code', 'content.innerContent', "a\x00\t\x01b\x1f\n\x7f"));
self::assertSame("<p>a\nb\r</p>", $writer->cleanValue('divi/text', 'content.innerContent', "<p>a\x0b\nb\r\x08</p>"));
self::assertSame('Titel', $writer->cleanValue('divi/heading', 'title.innerContent', "Ti\x00tel\x7f"));
// S32a: controls go on the RAW value, so a control-split marker IS a marker: the value goes
// through canonical() like any reference – validated, cleaned (S21) and re-serialized.
$tail = '({"type":"content","value":{"name":"post_title","settings":{"before":"<script>x()</script>","after":""}}})$';
$canonical = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$';
$targets = [
'text' => ['divi/heading', 'title.innerContent'],
'html' => ['divi/text', 'content.innerContent'],
'code' => ['divi/code', 'content.innerContent'],
];
foreach ([chr(0), chr(1), chr(8), chr(27), chr(127)] as $control) {
$value = '$variable' . $control . $tail;
// The fake keeps them like sanitize_text_field does: the production code handles it.
self::assertStringNotContainsString('$variable(', ComposeFixture::sanitizer()->text('$variable' . $control . '(x'));
foreach ($targets as $kind => [$module, $path]) {
self::assertSame($canonical, $writer->cleanValue($module, $path, $value), $kind . ' ' . ord($control));
}
// Embedded in text it is an embedded reference: refused.
try {
$writer->cleanValue('divi/heading', 'title.innerContent', 'Hallo $variable' . $control . $tail);
self::fail('embedded control-split reference accepted');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('allein im Wert', $e->getMessage());
}
}
}
/**
* Ruling S32a: "<" followed by VT/FF is a literal "<" for strip_tags / sanitize_text_field;
* stripping the control character only after the sanitizer would build a real tag. Controls
* go on the RAW value, so the sanitizer sees – and removes – the tag.
*/
public function testControlCharactersGoBeforeTheSanitizer(): void
{
$writer = ComposeFixture::attrWriter();
$img = '<' . chr(11) . 'img src=x onerror=alert(1)>';
$script = '<' . chr(12) . 'script>alert(1)</script>';
// The fake reproduces the WordPress behaviour the attack relies on.
self::assertStringContainsString('<' . chr(11) . 'img', ComposeFixture::sanitizer()->text($img));
foreach ([$img . 'Titel', $script . 'Titel'] as $value) {
$result = $writer->apply('divi/heading', self::HEADING, ['title.innerContent' => ['desktop' => $value]], 'attrs');
self::assertSame([], $result['errors']);
$stored = $result['attrs']['title']['innerContent']['desktop']['value'];
self::assertStringNotContainsString('<', $stored);
$button = $writer->cleanValue('divi/button', 'button.innerContent', $value, 'text');
self::assertStringNotContainsString('<', $button);
}
}
/** Ruling S34: value keys are whole identifiers – "$" must not match before a trailing newline. */
public function testValueKeysRejectATrailingNewline(): void
{
$result = ComposeFixture::attrWriter()->apply('divi/text', self::TEXT, [
'module.decoration.spacing' => ['desktop' => ['padding' => ["top\n" => '1px']]],
], 'attrs');
self::assertStringContainsString('Schlüssel', $result['errors']['attrs.module.decoration.spacing']);
}
/** Ruling S33a: a kind override may only tighten the routed kind (code < html < text; url is its own family). */
public function testKindOverrideMayOnlyTighten(): void
{
$writer = ComposeFixture::attrWriter();
self::assertSame('x', $writer->cleanValue('divi/text', 'content.innerContent', '<p>x</p>', null, 'text'));
self::assertSame('x', $writer->cleanValue('divi/code', 'content.innerContent', '<b>x</b>', null, 'text'));
self::assertSame('<p>x</p>', $writer->cleanValue('divi/code', 'content.innerContent', '<p>x</p><script>y</script>', null, 'html'));
self::assertSame('Los', $writer->cleanValue('divi/heading', 'title.innerContent', '<b>Los</b>', null, 'text'));
self::assertSame('/a', $writer->cleanValue('divi/button', 'button.innerContent', '/a', 'linkUrl', 'url'));
$weaker = [
'html over text' => ['divi/heading', 'title.innerContent', null, 'html'],
'code over text' => ['divi/heading', 'title.innerContent', null, 'code'],
'code over html' => ['divi/text', 'content.innerContent', null, 'code'],
'url over text' => ['divi/heading', 'title.innerContent', null, 'url'],
'text over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'text'],
'html over url' => ['divi/button', 'button.innerContent', 'linkUrl', 'html'],
];
foreach ($weaker as $name => [$module, $path, $sub, $kind]) {
try {
$writer->cleanValue($module, $path, 'x', $sub, $kind);
self::fail($name . ' was accepted');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('Feldart', $e->getMessage(), $name);
}
}
}
/**
* Ruling S35: controls go once at the entry of apply(), before placeholder resolution – a
* control-split placeholder is the placeholder (resolved and checked), never literal text
* that slipped past the placeholder check. Also deep inside values.
*/
public function testControlCharactersGoBeforePlaceholderResolution(): void
{
$writer = ComposeFixture::attrWriter();
$reference = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$';
foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) {
$result = $writer->apply(
'divi/heading',
[],
['title.innerContent' => ['desktop' => '{' . $control . '{post:title}}']],
'attrs'
);
self::assertSame([], $result['errors'], (string) ord($control));
self::assertSame($reference, $result['attrs']['title']['innerContent']['desktop']['value']);
// Embedded: refused like "{{post:title}}" inside text.
$embedded = $writer->apply(
'divi/heading',
[],
['title.innerContent' => ['desktop' => 'Hallo {' . $control . '{post:title}}']],
'attrs'
);
self::assertStringContainsString('allein im Wert', $embedded['errors']['attrs.title.innerContent']);
self::assertSame([], $embedded['attrs']);
$deep = $writer->apply(
'divi/button',
[],
['button.innerContent' => ['desktop' => ['text' => "{{post:ti" . $control . "tle}}"]]],
'attrs'
);
self::assertSame([], $deep['errors']);
self::assertSame($reference, $deep['attrs']['button']['innerContent']['desktop']['value']['text']);
}
}
}