Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
224 lines
11 KiB
PHP
224 lines
11 KiB
PHP
<?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\TreeEditor;
|
||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||
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)])
|
||
);
|
||
}
|
||
}
|