Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php
T
janniclas.loosenandClaude Opus 5.5 b1dd35abd8 feat: Divi tools edit Theme Builder layouts; visible layouts need confirm
TB layouts load through ThemeBuilderAccess (P12), live layouts (attached +
enabled, Q8) need a dry run and confirm (Q9) and get the shared restore point
of VisibleEdits, extracted from GlobalEdits (Q2: backup when revisions are
off, P9 refusals, P14 naming). divi/post-content only in body layouts (F22),
also for nested stored nodes. SaveScope::ThemeBuilderLayout sets Divi flags
and clears site CSS + TB cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:45:07 +02:00

248 lines
12 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 LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Document;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
final class TreeEditorTest extends TestCase
{
private function document(string $name = 'synthetic-basic.html'): Document
{
return (new Parser())->parse((string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/' . $name));
}
public function testResolvesPositions(): void
{
$document = $this->document();
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$end = $editor->position($index, []);
self::assertSame($index->root(), $end['container']);
self::assertNull($end['parent']);
$inColumn = $editor->position($index, ['parent' => 's0.r0.c1', 'index' => 1]);
self::assertSame($index->find('s0.r0.c1'), $inColumn['parent']);
self::assertSame(1, $inColumn['index']);
$after = $editor->position($index, ['after' => 's0.r0.c1.m0']);
self::assertSame($index->find('s0.r0.c1'), $after['container']);
self::assertSame(1, $after['index']);
$beforeSection = $editor->position($index, ['before' => 's1']);
self::assertNull($beforeSection['parent']);
self::assertSame(1, $beforeSection['index']);
self::assertSame('nur eines von parent, before oder after angeben', $editor->position($index, ['parent' => 's0', 'after' => 's1']));
self::assertSame('Knoten "s9" gibt es nicht', $editor->position($index, ['before' => 's9']));
}
public function testInsertChecksTheStructureRules(): void
{
$document = $this->document();
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$button = Block::create('divi/button');
self::assertSame(
'auf oberster Ebene sind nur Abschnitte (section) erlaubt',
$editor->insert($editor->position($index, []), [$button])
);
self::assertStringContainsString('Zeilen', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [$button]));
self::assertStringContainsString('Spalten entstehen mit der Zeile', (string) $editor->insert($editor->position($index, ['parent' => 's0.r0']), [$button]));
self::assertStringContainsString('accordion-item', (string) $editor->insert($editor->position($index, ['parent' => 's1.r0.c1.m0']), [$button]));
self::assertNull($editor->insert($editor->position($index, ['after' => 's0.r0.c1.m0']), [$button]));
self::assertSame('divi/button', NodeIndex::build($document)->find('s0.r0.c1.m1')?->name());
}
/**
* Ruling S9: inserting at the document root of an empty page (no divi/placeholder yet)
* wraps it in one first, the way DiviContentComposer (Task 9) writes new content.
*/
public function testInsertIntoAnEmptyPageCreatesAPlaceholder(): void
{
$document = new Document();
$index = NodeIndex::build($document);
self::assertSame($document, $index->root());
$editor = new TreeEditor(ComposeFixture::rules());
$section = Block::create('divi/section');
self::assertNull($editor->insert($editor->position($index, []), [$section]));
self::assertSame(
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->',
Serializer::document($document)
);
$rebuilt = NodeIndex::build($document);
self::assertSame($section, $rebuilt->find('s0'));
self::assertNull($rebuilt->parentId($section), 'the placeholder is transparent, like every other top-level node');
// A second insert on the now non-empty page must not wrap it again.
$second = Block::create('divi/section');
self::assertNull($editor->insert($editor->position($rebuilt, []), [$second]));
self::assertSame(1, substr_count(Serializer::document($document), '<!-- wp:divi/placeholder -->'));
}
public function testMoveWithinAndAcrossParents(): void
{
$document = $this->document();
$original = Serializer::document($document);
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$button = $index->find('s0.r0.c1.m2');
self::assertNull($editor->move($index, $button, $editor->position($index, ['before' => 's0.r0.c1.m0'])));
$index = NodeIndex::build($document);
self::assertSame($button, $index->find('s0.r0.c1.m0'));
self::assertNull($editor->move($index, $button, $editor->position($index, ['parent' => 's1.r0.c0'])));
$index = NodeIndex::build($document);
self::assertSame($button, $index->find('s1.r0.c0.m1'));
self::assertStringContainsString('Termin buchen', Serializer::document($document));
// Ruling S14: move it back (exact inverse of the two moves above) and assert the
// serialized page is byte-identical to the original – not merely the same length.
self::assertNull($editor->move($index, $button, $editor->position($index, ['before' => 's0.r0.c1.m0'])));
$index = NodeIndex::build($document);
self::assertNull($editor->move($index, $button, $editor->position($index, ['after' => 's0.r0.c1.m2'])));
self::assertSame($original, Serializer::document($document), 'moving a node back restores every byte');
$section = $index->find('s0');
self::assertSame(
'ein Knoten kann nicht in sich selbst verschoben werden',
$editor->move($index, $section, $editor->position($index, ['parent' => 's0.r0.c0']))
);
self::assertNull($editor->move($index, $section, $editor->position($index, [])));
self::assertSame($section, NodeIndex::build($document)->find('s1'));
}
/** Ledger T4: move() checks StructureRules for the target parent and leaves the tree unchanged when it refuses. */
public function testMoveChecksTheStructureRules(): void
{
$document = $this->document();
$original = Serializer::document($document);
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$button = $index->find('s0.r0.c1.m2');
self::assertSame(
'auf oberster Ebene sind nur Abschnitte (section) erlaubt',
$editor->move($index, $button, $editor->position($index, []))
);
self::assertStringContainsString('Zeilen', (string) $editor->move($index, $button, $editor->position($index, ['parent' => 's0'])));
self::assertStringContainsString('Spalten entstehen mit der Zeile', (string) $editor->move($index, $button, $editor->position($index, ['parent' => 's0.r0'])));
self::assertStringContainsString('accordion-item', (string) $editor->move($index, $button, $editor->position($index, ['parent' => 's1.r0.c1.m0'])));
self::assertSame($original, Serializer::document($document), 'a refused move changes nothing');
self::assertSame($button, NodeIndex::build($document)->find('s0.r0.c1.m2'));
}
public function testRemoveAndDuplicate(): void
{
$document = $this->document();
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$copy = $editor->duplicate($index, $index->find('s1.r0.c1.m0'));
$index = NodeIndex::build($document);
self::assertSame($copy, $index->find('s1.r0.c1.m1'));
self::assertSame(Serializer::block($index->find('s1.r0.c1.m0')), Serializer::block($copy));
$editor->remove($index, $index->find('s0'));
self::assertSame('divi/section', NodeIndex::build($document)->find('s0')?->name());
self::assertNull(NodeIndex::build($document)->find('s1'));
}
/**
* Duplicate never mutates an opaque block's internals; a copy of a global module stays a
* byte-identical reference, and the original is untouched (spec §5: byte-identical roundtrip).
*/
public function testDuplicateNeverMutatesAnOpaqueGlobalModule(): void
{
$document = $this->document('synthetic-mixed.html');
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$globalRow = $index->find('s1.g1');
self::assertNotNull($globalRow);
self::assertTrue($globalRow->isOpaque());
$originalBytes = Serializer::block($globalRow);
$copy = $editor->duplicate($index, $globalRow);
self::assertNotSame($globalRow, $copy);
self::assertTrue($copy->isOpaque());
self::assertSame($originalBytes, Serializer::block($copy), 'the copy is byte-identical to the original');
self::assertSame($originalBytes, Serializer::block($globalRow), 'the original is left untouched');
$index = NodeIndex::build($document);
self::assertSame($globalRow, $index->find('s1.g1'));
self::assertSame($copy, $index->find('s1.g2'));
}
public function testReadOnlyTargetsAndSpecialtySections(): void
{
$document = $this->document('synthetic-mixed.html');
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
$text = Block::create('divi/text');
self::assertStringContainsString('nur lesbar', (string) $editor->insert($editor->position($index, ['parent' => 's1.g1']), [$text]));
self::assertStringContainsString('Spezialabschnitte', (string) $editor->insert($editor->position($index, ['parent' => 's0']), [Block::create('divi/row')]));
self::assertNull($editor->insert($editor->position($index, ['parent' => 's0.c0']), [$text]));
}
/** A global instance counts as its root type: a section instance at the top, a module instance in a column. */
public function testGlobalInstancesFollowTheRulesOfTheirRootType(): void
{
$document = $this->document('stage3/page--globale-instanzen.html');
$index = NodeIndex::build($document);
$editor = new TreeEditor(ComposeFixture::rules());
self::assertNull($editor->move($index, $index->find('g0'), $editor->position($index, ['after' => 's1'])));
$index = NodeIndex::build($document);
$module = GlobalInstance::of(Block::create('divi/text'), 1059);
self::assertNull($editor->insert($editor->position($index, ['parent' => 's1.r0.c0']), [$module]));
self::assertSame(
'auf oberster Ebene sind nur Abschnitte (section) erlaubt',
$editor->insert($editor->position($index, []), [GlobalInstance::of(Block::create('divi/text'), 1059)])
);
}
/**
* F22 for stored nodes (node-copy, library-insert, duplicate): a copied section that holds divi/post-content
* is refused outside a body layout, however deep the module sits.
*/
public function testInsertChecksTheThemeBuilderAreaOfNestedModules(): void
{
$body = (new Parser())->parse(DiviFixtures::markup('stage3/tb-body--body-mit-dynamic-content.html'));
$section = NodeIndex::build($body)->find('s0')?->copy();
self::assertInstanceOf(Block::class, $section);
$header = (new Parser())->parse(DiviFixtures::markup('stage3/tb-header--globaler-header.html'));
$headerIndex = NodeIndex::build($header);
$editor = new TreeEditor(new StructureRules(DiviFixtures::catalog(), 'header'));
self::assertSame(
'divi/post-content zeigt Divi im Bereich „header“ nicht (nur body)',
$editor->insert($editor->position($headerIndex, []), [$section->copy()])
);
$bodyEditor = new TreeEditor(new StructureRules(DiviFixtures::catalog(), 'body'));
self::assertNull($bodyEditor->insert($bodyEditor->position(NodeIndex::build($body), []), [$section->copy()]));
}
}