feat: Divi tree editor and quality warnings
TreeEditor (position/insert/move/remove/duplicate on the BlockTree) and Warnings (post-write quality checks), plus S18's outline breadth limit on the 2a Outline/DiviOutlineAbility. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -21,10 +21,12 @@ final class OutlineTest extends TestCase
|
||||
$outline = new Outline(['a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' => 'Hero-Titel']);
|
||||
|
||||
$rows = $outline->rows($index);
|
||||
self::assertFalse($outline->wasTruncated());
|
||||
|
||||
self::assertSame(
|
||||
['id' => 's0.r0.c1.m0', 'type' => 'heading', 'label' => '', 'excerpt' => 'Willkommen bei Müller & Söhne',
|
||||
'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3],
|
||||
'info' => '', 'presets' => ['Hero-Titel'], 'children' => 0, 'editable' => true, 'depth' => 3,
|
||||
'truncated' => false],
|
||||
$rows[5]
|
||||
);
|
||||
self::assertSame('Spalten 8_24+16_24', $rows[1]['info']);
|
||||
@@ -50,6 +52,36 @@ final class OutlineTest extends TestCase
|
||||
self::assertSame(2, $rows[1]['children']);
|
||||
}
|
||||
|
||||
/** Ruling S18: outline breadth limit – caps children per parent, marks the parent truncated. */
|
||||
public function testBreadthLimitCapsChildrenPerParentAndMarksTruncation(): void
|
||||
{
|
||||
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
|
||||
$outline = new Outline();
|
||||
|
||||
// s0.r0.c1 has three children (heading, text, button); cap them at two.
|
||||
$rows = $outline->rows($index, 's0.r0.c1', null, 2);
|
||||
|
||||
self::assertSame(['s0.r0.c1', 's0.r0.c1.m0', 's0.r0.c1.m1'], array_column($rows, 'id'));
|
||||
self::assertTrue($rows[0]['truncated'], 'the parent whose children were cut is marked');
|
||||
self::assertFalse($rows[1]['truncated']);
|
||||
self::assertTrue($outline->wasTruncated());
|
||||
|
||||
$full = (new Outline())->rows($index, 's0.r0.c1');
|
||||
self::assertSame(4, count($full), 'the default limit (50) does not cut three children');
|
||||
}
|
||||
|
||||
/** The page root has no row of its own, so a top-level cap can only show up in wasTruncated(). */
|
||||
public function testBreadthLimitAtTheTopLevelStillReportsTruncation(): void
|
||||
{
|
||||
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-basic.html')));
|
||||
$outline = new Outline();
|
||||
|
||||
$rows = $outline->rows($index, null, 0, 1);
|
||||
|
||||
self::assertSame(['s0'], array_column($rows, 'id'));
|
||||
self::assertTrue($outline->wasTruncated());
|
||||
}
|
||||
|
||||
public function testReadOnlyNodesAreMarked(): void
|
||||
{
|
||||
$index = NodeIndex::build((new Parser())->parse($this->fixture('synthetic-mixed.html')));
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<?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\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||||
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'));
|
||||
}
|
||||
|
||||
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]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\Compose\Warnings;
|
||||
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class WarningsTest extends TestCase
|
||||
{
|
||||
public function testWarnsAboutTheTouchedNodesOnly(): void
|
||||
{
|
||||
$composed = ComposeFixture::composer()->compose([
|
||||
['type' => 'section', 'label' => 'Test', 'modules' => [
|
||||
['type' => 'heading', 'text' => 'Titel', 'level' => 'h1'],
|
||||
['type' => 'heading', 'text' => 'Zu tief', 'level' => 'h4'],
|
||||
['type' => 'image', 'src' => 'https://kunde.test/a.jpg'],
|
||||
['type' => 'text', 'content' => ''],
|
||||
['type' => 'code', 'content' => '<div>x</div>'],
|
||||
['type' => 'accordion'],
|
||||
['type' => 'button', 'text' => 'Los', 'attrs' => [
|
||||
'button.decoration.background' => ['desktop' => ['color' => '#2ea3f3']],
|
||||
'button.decoration.font.font' => ['desktop' => ['family' => 'Open Sans', 'color' => '#abcdef']],
|
||||
]],
|
||||
]],
|
||||
], null, 'nodes');
|
||||
self::assertSame([], $composed['errors']);
|
||||
$document = new Document();
|
||||
foreach ($composed['blocks'] as $block) {
|
||||
$document->append($block);
|
||||
}
|
||||
|
||||
$warnings = (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), $composed['blocks']);
|
||||
|
||||
self::assertSame(
|
||||
[
|
||||
's0.r0.c0.m2: Bild ohne Alt-Text.',
|
||||
's0.r0.c0.m3: text ist leer (content fehlt).',
|
||||
's0.r0.c0.m4: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein '
|
||||
. 'natives Modul passt.',
|
||||
's0.r0.c0.m5: accordion ohne Einträge.',
|
||||
's0.r0.c0.m6: feste Farbe #2ea3f3 – globale Farbe "Primärfarbe" ({{color:primary}}) passt.',
|
||||
's0.r0.c0.m6: feste Schrift Open Sans – {{font:heading}} verwenden.',
|
||||
's0.r0.c0.m1: Sprung in der Überschriftenhierarchie (h4 nach h1).',
|
||||
],
|
||||
$warnings
|
||||
);
|
||||
|
||||
self::assertSame([], (new Warnings(DesignSystemTest::sample()))->collect(NodeIndex::build($document), []));
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,25 @@ final class DiviReadAbilitiesTest extends TestCase
|
||||
self::assertNotSame($result['tree_rev'], $result['rev']);
|
||||
self::assertCount(16, $result['nodes']);
|
||||
self::assertStringStartsWith('s0 section "Hero"', $result['outline']);
|
||||
self::assertFalse($result['truncated']);
|
||||
self::assertSame([], $result['hints']);
|
||||
self::assertTrue($ability->isDirect());
|
||||
self::assertTrue($ability->annotations()['readonly']);
|
||||
}
|
||||
|
||||
/** Ruling S18: the "limit" input caps children per parent; "truncated" reports the cut. */
|
||||
public function testOutlineBreadthLimitTruncatesAndReportsIt(): void
|
||||
{
|
||||
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html'));
|
||||
$ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design());
|
||||
|
||||
$result = $ability->execute(['post_id' => 7, 'id' => 's0.r0.c1', 'limit' => 2]);
|
||||
|
||||
self::assertSame(['s0.r0.c1', 's0.r0.c1.m0', 's0.r0.c1.m1'], array_column($result['nodes'], 'id'));
|
||||
self::assertTrue($result['nodes'][0]['truncated']);
|
||||
self::assertTrue($result['truncated']);
|
||||
}
|
||||
|
||||
public function testOutlineOfASubtreeAndUnknownNode(): void
|
||||
{
|
||||
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html'));
|
||||
|
||||
Reference in New Issue
Block a user