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:
2026-09-26 14:42:26 +02:00
co-authored by Claude Opus 5.5
parent 8688a02402
commit ea1fd607b2
8 changed files with 791 additions and 7 deletions
@@ -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')));