Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/BlockTree/ParserTest.php
T
janniclas.loosenandClaude Opus 5.5 4137882008 fix: harden the Divi BlockTree against opaque/invalid writes
Final fix wave for stage 2a, load-bearing for 2b's writers:
- Block::setAttrs()/Container::insertChild()/removeChild() refuse on an opaque
  block (undecodable attrs or a global module) instead of silently corrupting it.
- Container::append() is now @internal (Parser only) and refuses on a void
  block, so a child can no longer be silently dropped from the serialized output.
- Block::create() validates the block name against namespace/block-name.
- Json::decodeObject() returns null (-> opaque) for a top-level object whose
  keys are a plain list ("0","1",...) rather than returning [] and letting the
  Serializer re-encode it as a corrupting JSON array.
- AttrMap::set() rejects empty path segments and keeps an existing stdClass
  value an object when rewrapping it (no silent object->list flip); AttrMap::get()
  is updated in step so it can still read back through such an object.
- Block::copy() deep-clones stdClass attribute values instead of sharing them.
- Json::encode()'s existing JsonException-on-invalid-UTF-8 behaviour is documented.

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

149 lines
5.9 KiB
PHP

<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use PHPUnit\Framework\TestCase;
use stdClass;
final class ParserTest extends TestCase
{
/** @return array<string, array{string}> Every fixture page, also with Windows line endings. */
public static function fixtures(): array
{
$cases = [];
foreach (glob(dirname(__DIR__, 4) . '/Fixtures/divi/*.html') ?: [] as $file) {
$content = (string) file_get_contents($file);
$cases[basename($file)] = [$content];
$cases[basename($file) . ' (CRLF)'] = [str_replace("\n", "\r\n", $content)];
}
return $cases;
}
/** @dataProvider fixtures */
public function testParseThenSerializeIsByteIdentical(string $content): void
{
self::assertSame($content, Serializer::document((new Parser())->parse($content)));
}
public function testFixturesArePresent(): void
{
$names = array_map('basename', glob(dirname(__DIR__, 4) . '/Fixtures/divi/*.html') ?: []);
self::assertContains('synthetic-basic.html', $names);
self::assertContains('synthetic-mixed.html', $names);
self::assertContains('converted-landing.html', $names);
self::assertContains('converted-features.html', $names);
}
public function testBuildsTheTree(): void
{
$document = (new Parser())->parse($this->fixture('synthetic-basic.html'));
$placeholder = $document->children()[0];
self::assertSame('divi/placeholder', $placeholder->name());
$sections = $placeholder->children();
self::assertCount(2, $sections);
self::assertSame('divi/section', $sections[0]->name());
self::assertFalse($sections[0]->isOpaque());
self::assertSame(
'$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$',
$sections[0]->attrs()['module']['decoration']['background']['desktop']['value']['color']
);
$columns = $sections[0]->children()[0]->children();
self::assertSame(['divi/column', 'divi/column'], array_map(static fn (Block $b): string => $b->name(), $columns));
self::assertTrue($columns[0]->children()[0]->isVoid());
self::assertSame('image', $columns[0]->children()[0]->shortName());
self::assertSame(
'Willkommen bei Müller & Söhne',
$columns[1]->children()[0]->attrs()['title']['innerContent']['desktop']['value']
);
}
public function testKeepsTextBetweenBlocksAndNamesCoreBlocks(): void
{
$document = (new Parser())->parse("<!-- wp:paragraph -->\n<p>x</p>\n<!-- /wp:paragraph -->\n\ntext");
self::assertSame('core/paragraph', $document->children()[0]->name());
self::assertSame(["\n<p>x</p>\n"], $document->children()[0]->parts());
self::assertSame("\n\ntext", $document->parts()[1]);
}
public function testMarksGlobalAndUndecodableBlocksOpaque(): void
{
$document = (new Parser())->parse($this->fixture('synthetic-mixed.html'));
$placeholder = $document->children()[1];
$second = $placeholder->children()[1];
[$globalLayout, $globalRow, $globalWithChild] = $second->children();
self::assertTrue($globalLayout->isOpaque());
self::assertTrue($globalLayout->isGlobal());
self::assertTrue($globalRow->isOpaque());
self::assertTrue($globalRow->isGlobal());
self::assertTrue($globalWithChild->isOpaque());
self::assertFalse($second->isOpaque());
$broken = (new Parser())->parse('<!-- wp:divi/text {"a":} /-->');
self::assertTrue($broken->children()[0]->isOpaque());
self::assertSame([], $broken->children()[0]->attrs());
}
public function testMarksListLikeTopLevelAttributesOpaque(): void
{
$content = '<!-- wp:divi/text {"0":"a","1":"b"} /-->';
$document = (new Parser())->parse($content);
self::assertTrue($document->children()[0]->isOpaque());
self::assertSame([], $document->children()[0]->attrs());
self::assertSame($content, Serializer::document($document));
}
public function testKeepsEmptyAndListLikeObjectsAsObjects(): void
{
$document = (new Parser())->parse($this->fixture('synthetic-mixed.html'));
$attrs = $document->children()[1]->children()[1]->attrs();
self::assertInstanceOf(stdClass::class, $attrs['module']['decoration']['spacing']['desktop']['value']);
self::assertSame([], $attrs['module']['decoration']['background']['desktop']['value']);
self::assertInstanceOf(stdClass::class, $attrs['localAttrs']);
}
public function testDivi4ShortcodesAreOneTextPart(): void
{
$content = $this->fixture('divi4-shortcodes.html');
$document = (new Parser())->parse($content);
self::assertSame([], $document->children());
self::assertSame([$content], $document->parts());
}
/** @return array<string, array{string}> */
public static function malformed(): array
{
return [
'closer without opener' => ['<!-- /wp:divi/section -->'],
'unclosed block' => ['<!-- wp:divi/section --><!-- wp:divi/row --><!-- /wp:divi/row -->'],
'crossed blocks' => ['<!-- wp:divi/row --><!-- wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/column -->'],
];
}
/** @dataProvider malformed */
public function testRefusesUnbalancedMarkup(string $content): void
{
$this->expectException(MalformedMarkup::class);
(new Parser())->parse($content);
}
private function fixture(string $name): string
{
return (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name);
}
}