diff --git a/src/Integrations/Divi/BlockTree/Outline.php b/src/Integrations/Divi/BlockTree/Outline.php index 332b293..63ea24a 100644 --- a/src/Integrations/Divi/BlockTree/Outline.php +++ b/src/Integrations/Divi/BlockTree/Outline.php @@ -7,21 +7,44 @@ namespace LIT\MCP\Integrations\Divi\BlockTree; /** * The page tree as rows and as compact text (lit-divi-outline, spec §5.3/§5.7; also the * "outline" of write results). Preset IDs are shown with their names when known. + * + * Ruling S18: a page can have far more nodes at one level than are useful to show (e.g. a + * 200-item slider). rows() therefore caps the number of *direct* children shown per parent + * (breadth, not depth) at $limit; the parent whose children got cut is marked + * 'truncated' => true. wasTruncated() additionally reports whether ANY cap fired, including at + * the top level (the page root has no row of its own to mark). */ final class Outline { + /** Sane default breadth cap per parent when no explicit limit is given. */ + public const DEFAULT_LIMIT = 50; + + private bool $truncated = false; + /** @param array $presetNames Preset ID => name. */ public function __construct(private array $presetNames = []) { } + /** Whether the last rows() call cut off any children (at any level, including the top). */ + public function wasTruncated(): bool + { + return $this->truncated; + } + /** * @return list, children: int, editable: bool, depth: int}> + * presets: list, children: int, editable: bool, depth: int, truncated: bool}> */ - public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null): array + public function rows(NodeIndex $index, ?string $rootId = null, ?int $maxDepth = null, ?int $limit = null): array { + $limit = $limit ?? self::DEFAULT_LIMIT; + $this->truncated = false; + $rows = []; + $rowIndexById = []; + $counts = []; + $skipped = []; $baseDepth = null; foreach ($index->entries() as $entry) { $id = $entry['id']; @@ -29,12 +52,30 @@ final class Outline continue; } + $parentId = $index->parentId($entry['block']); + if ($parentId !== null && isset($skipped[$parentId])) { + $skipped[$id] = true; + continue; + } + $baseDepth ??= $entry['depth']; $depth = $entry['depth'] - $baseDepth; if ($maxDepth !== null && $depth > $maxDepth) { continue; } + $parentKey = $parentId ?? ''; + $counts[$parentKey] = ($counts[$parentKey] ?? 0) + 1; + if ($counts[$parentKey] > $limit) { + $this->truncated = true; + $skipped[$id] = true; + if ($parentId !== null && isset($rowIndexById[$parentId])) { + $rows[$rowIndexById[$parentId]]['truncated'] = true; + } + continue; + } + + $rowIndexById[$id] = count($rows); $rows[] = $this->row($entry['block'], $id, $depth); } @@ -52,6 +93,7 @@ final class Outline $line .= $row['presets'] !== [] ? ' {Preset: ' . implode(', ', $row['presets']) . '}' : ''; $line .= $row['excerpt'] !== '' ? ' – ' . $row['excerpt'] : ''; $line .= $row['editable'] ? '' : ' (nur lesbar)'; + $line .= ($row['truncated'] ?? false) ? ' (weitere Kinder abgeschnitten)' : ''; $lines[] = $line; } @@ -60,7 +102,7 @@ final class Outline /** * @return array{id: string, type: string, label: string, excerpt: string, info: string, - * presets: list, children: int, editable: bool, depth: int} + * presets: list, children: int, editable: bool, depth: int, truncated: bool} */ private function row(Block $block, string $id, int $depth): array { @@ -79,6 +121,7 @@ final class Outline 'children' => $editable ? count($block->children()) : 0, 'editable' => $editable, 'depth' => $depth, + 'truncated' => false, ]; } } diff --git a/src/Integrations/Divi/Compose/TreeEditor.php b/src/Integrations/Divi/Compose/TreeEditor.php new file mode 100644 index 0000000..f859721 --- /dev/null +++ b/src/Integrations/Divi/Compose/TreeEditor.php @@ -0,0 +1,165 @@ + $input + * @return array{container: Container, parent: ?Block, index: int}|string Position or German error. + */ + public function position(NodeIndex $index, array $input): array|string + { + $given = array_filter( + [ + 'parent' => $input['parent'] ?? null, + 'before' => $input['before'] ?? null, + 'after' => $input['after'] ?? null, + ], + static fn (mixed $value): bool => is_string($value) && $value !== '' + ); + if (count($given) > 1) { + return 'nur eines von parent, before oder after angeben'; + } + + $at = isset($input['index']) && is_int($input['index']) ? max(0, $input['index']) : PHP_INT_MAX; + if ($given === []) { + return ['container' => $index->root(), 'parent' => null, 'index' => $at]; + } + + $key = (string) array_key_first($given); + $node = $index->find((string) $given[$key]); + if ($node === null) { + return sprintf('Knoten "%s" gibt es nicht', (string) $given[$key]); + } + + if ($key === 'parent') { + return ['container' => $node, 'parent' => $node, 'index' => $at]; + } + + $container = $index->parentOf($node); + if ($container === null) { + return sprintf('Knoten "%s" hat keinen Elternknoten', (string) $given[$key]); + } + + $sibling = (int) $container->indexOf($node); + + return [ + 'container' => $container, + 'parent' => $container instanceof Block && $container->name() !== 'divi/placeholder' ? $container : null, + 'index' => $key === 'before' ? $sibling : $sibling + 1, + ]; + } + + /** + * @param array{container: Container, parent: ?Block, index: int} $position + * @param list $blocks + */ + public function insert(array $position, array $blocks): ?string + { + foreach ($blocks as $block) { + $violation = $this->rules->violation($position['parent'], $block->name()); + if ($violation !== null) { + return $violation; + } + } + + $container = $this->targetContainer($position['container']); + $start = $position['index']; + foreach (array_values($blocks) as $offset => $block) { + $at = $start === PHP_INT_MAX ? PHP_INT_MAX : $start + $offset; + $container->insertChild($at, $block); + } + + return null; + } + + /** @param array{container: Container, parent: ?Block, index: int} $position */ + public function move(NodeIndex $index, Block $node, array $position): ?string + { + for ($ancestor = $position['parent']; $ancestor !== null; $ancestor = $this->parentBlock($index, $ancestor)) { + if ($ancestor === $node) { + return 'ein Knoten kann nicht in sich selbst verschoben werden'; + } + } + + $violation = $this->rules->violation($position['parent'], $node->name()); + if ($violation !== null) { + return $violation; + } + + $from = $index->parentOf($node); + if ($from === null) { + return 'der Knoten hat keinen Elternknoten'; + } + + $old = $from->removeChild($node); + $target = $position['index']; + if ($from === $position['container'] && $target !== PHP_INT_MAX && $old < $target) { + --$target; + } + $position['container']->insertChild($target, $node); + + return null; + } + + public function remove(NodeIndex $index, Block $node): void + { + $index->parentOf($node)?->removeChild($node); + } + + /** Inserts a copy right after $node and returns it. Never touches $node or the copy afterwards. */ + public function duplicate(NodeIndex $index, Block $node): Block + { + $copy = $node->copy(); + $container = $index->parentOf($node); + if ($container !== null) { + $container->insertChild((int) $container->indexOf($node) + 1, $copy); + } + + return $copy; + } + + private function parentBlock(NodeIndex $index, Block $block): ?Block + { + $parent = $index->parentOf($block); + + return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null; + } + + /** + * Ruling S9: inserting at the document root of an empty page (no divi/placeholder yet) + * first wraps it in one, the same way DiviContentComposer (Task 9) writes new content – + * so a first-section page gets the structure the Visual Builder itself writes. + */ + private function targetContainer(Container $container): Container + { + if (! $container instanceof Document || $container->children() !== []) { + return $container; + } + + $placeholder = Block::create('divi/placeholder'); + $container->insertChild(0, $placeholder); + + return $placeholder; + } +} diff --git a/src/Integrations/Divi/Compose/Warnings.php b/src/Integrations/Divi/Compose/Warnings.php new file mode 100644 index 0000000..e182a96 --- /dev/null +++ b/src/Integrations/Divi/Compose/Warnings.php @@ -0,0 +1,269 @@ + 1, 'divi/blurb' => 4, 'divi/cta' => 2]; + + private const IMAGE_SOURCES = [ + 'divi/image' => ['image.innerContent', 'src', 'alt'], + 'divi/blurb' => ['imageIcon.innerContent', 'src', 'alt'], + 'divi/slide' => ['image.innerContent', 'src', 'alt'], + ]; + + public function __construct(private DesignSystem $design) + { + } + + /** + * @param list $touched Created or changed blocks (their descendants count too). + * @return list + */ + public function collect(NodeIndex $index, array $touched): array + { + $blocks = []; + foreach ($touched as $block) { + foreach ($this->withDescendants($block) as $node) { + $blocks[spl_object_id($node)] = $node; + } + } + + $warnings = []; + foreach ($blocks as $block) { + $id = $index->idOf($block) ?? '?'; + array_push( + $warnings, + ...$this->code($block, $id), + ...$this->image($block, $id), + ...$this->empty($block, $id), + ...$this->fixedValues($block, $id) + ); + } + + return array_values(array_unique(array_merge($warnings, $this->headings($index, $blocks)))); + } + + /** + * Opaque blocks (global modules) are never descended into – their content is edited only in + * the Divi Builder (Etappe 3), so no warning ever looks inside one. + * + * @return list + */ + private function withDescendants(Block $block): array + { + $all = [$block]; + if (! $block->isOpaque()) { + foreach ($block->children() as $child) { + array_push($all, ...$this->withDescendants($child)); + } + } + + return $all; + } + + /** @return list */ + private function code(Block $block, string $id): array + { + return in_array($block->name(), ['divi/code', 'divi/fullwidth-code'], true) + ? [sprintf('%s: Code-Modul verwendet – im Divi-Builder nicht visuell bearbeitbar; nur nutzen, wenn kein ' + . 'natives Modul passt.', $id)] + : []; + } + + /** @return list */ + private function image(Block $block, string $id): array + { + $spec = self::IMAGE_SOURCES[$block->name()] ?? null; + if ($spec === null) { + return []; + } + + $value = AttrMap::desktop($block->attrs(), $spec[0]); + $src = is_array($value) ? ($value[$spec[1]] ?? '') : ''; + $alt = is_array($value) ? ($value[$spec[2]] ?? '') : ''; + + return is_string($src) && $src !== '' && (! is_string($alt) || trim($alt) === '') + ? [sprintf('%s: Bild ohne Alt-Text.', $id)] + : []; + } + + /** @return list */ + private function empty(Block $block, string $id): array + { + $name = $block->name(); + if (in_array($name, ['divi/section', 'divi/row', 'divi/column', 'divi/group'], true)) { + return $block->children() === [] ? [sprintf('%s: leer (%s ohne Inhalt).', $id, $block->shortName())] : []; + } + + $type = $block->shortName(); + if (ShortformMap::childType($type) !== null && $block->children() === []) { + return [sprintf('%s: %s ohne Einträge.', $id, $type)]; + } + + $primary = ShortformMap::primary($type); + if ($primary === null || ShortformMap::childType($type) !== null) { + return []; + } + + $values = ShortformMap::values($type, $block->attrs()); + $value = $values[$primary] ?? ''; + + return NodeSummary::textOf($value) === '' ? [sprintf('%s: %s ist leer (%s fehlt).', $id, $type, $primary)] : []; + } + + /** @return list */ + private function fixedValues(Block $block, string $id): array + { + $warnings = []; + $fonts = array_filter($this->design->fonts()); + foreach ($this->leaves($block->attrs()) as [$key, $value]) { + if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($value)) === 1) { + $match = $this->similarColor($value); + if ($match !== null) { + $warnings[] = sprintf( + '%s: feste Farbe %s – globale Farbe "%s" (%s) passt.', + $id, + $value, + $match['label'], + DesignSystem::colorPlaceholder($match['id'], $match['label']) + ); + } + } elseif ($key === 'family') { + $font = array_search(mb_strtolower(trim($value)), array_map('mb_strtolower', $fonts), true); + if ($font !== false) { + $warnings[] = sprintf('%s: feste Schrift %s – {{font:%s}} verwenden.', $id, $value, $font); + } + } + } + + return $warnings; + } + + /** + * Heading jumps (h2 → h4) in page order where the later heading belongs to a touched block. + * + * @param array $touched + * @return list + */ + private function headings(NodeIndex $index, array $touched): array + { + $warnings = []; + $previous = null; + foreach ($index->entries() as $entry) { + $block = $entry['block']; + foreach ($this->levels($block) as $level) { + if ($previous !== null && $level > $previous + 1 && isset($touched[spl_object_id($block)])) { + $warnings[] = sprintf( + '%s: Sprung in der Überschriftenhierarchie (h%d nach h%d).', + $entry['id'], + $level, + $previous + ); + } + $previous = $level; + } + } + + return $warnings; + } + + /** @return list Heading levels a block renders, in order. */ + private function levels(Block $block): array + { + $default = self::HEADING_DEFAULTS[$block->name()] ?? null; + if ($default !== null) { + $font = AttrMap::desktop($block->attrs(), 'title.decoration.font.font'); + $level = is_array($font) && is_string($font['headingLevel'] ?? null) + ? $font['headingLevel'] + : 'h' . $default; + + return [(int) substr($level, 1)]; + } + + if ($block->name() === 'divi/text') { + $html = AttrMap::desktop($block->attrs(), 'content.innerContent'); + preg_match_all('/design->colors() as $color) { + $other = self::rgb($color['value']); + if ($rgb !== null && $other !== null && self::distance($rgb, $other) <= self::SIMILAR) { + return $color; + } + } + + return null; + } + + /** + * @param array $values + * @return list [last key, value] of every string leaf (Divi + * references excluded – a fixed value that references a global one is not "fixed"). + */ + private function leaves(array $values): array + { + $leaves = []; + foreach ($values as $name => $value) { + if (is_array($value)) { + array_push($leaves, ...$this->leaves($value)); + } elseif (is_string($value) && ! VariableRef::contains($value)) { + $leaves[] = [(string) $name, $value]; + } + } + + return $leaves; + } + + /** @return array{int, int, int}|null */ + private static function rgb(string $hex): ?array + { + $hex = ltrim(trim($hex), '#'); + if (preg_match('/^([0-9a-f]{3}|[0-9a-f]{6})$/i', $hex) !== 1) { + return null; + } + + if (strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + + return [(int) hexdec(substr($hex, 0, 2)), (int) hexdec(substr($hex, 2, 2)), (int) hexdec(substr($hex, 4, 2))]; + } + + /** + * @param array{int, int, int} $a + * @param array{int, int, int} $b + */ + private static function distance(array $a, array $b): float + { + return sqrt(($a[0] - $b[0]) ** 2 + ($a[1] - $b[1]) ** 2 + ($a[2] - $b[2]) ** 2); + } +} diff --git a/src/Integrations/Divi/Pages/DiviOutlineAbility.php b/src/Integrations/Divi/Pages/DiviOutlineAbility.php index 6407979..c1af55d 100644 --- a/src/Integrations/Divi/Pages/DiviOutlineAbility.php +++ b/src/Integrations/Divi/Pages/DiviOutlineAbility.php @@ -36,7 +36,8 @@ final class DiviOutlineAbility implements Ability { return 'Liest den Aufbau einer Divi-Seite: Knoten mit Pfad-ID (s0, s0.r1, s0.r1.c0.m2), Modultyp, ' . 'Admin-Label, Textauszug und Preset. Liefert tree_rev – Pflicht für jede Divi-Änderung. ' - . 'Optional nur ein Teilbaum (id) oder begrenzte Tiefe (depth).'; + . 'Optional nur ein Teilbaum (id), begrenzte Tiefe (depth) oder begrenzte Breite je Ebene (limit); ' + . 'abgeschnittene Kinder zeigt "truncated".'; } public function inputSchema(): array @@ -52,6 +53,13 @@ final class DiviOutlineAbility implements Ability 'maximum' => 10, 'description' => 'Maximale Tiefe unterhalb des Startknotens (0 = nur der Knoten).', ], + 'limit' => [ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 500, + 'description' => 'Höchstzahl Kind-Knoten je Elternknoten (Standard: ' + . Outline::DEFAULT_LIMIT . '). Überzählige Kinder werden abgeschnitten (truncated).', + ], ], 'required' => ['post_id'], 'additionalProperties' => false, @@ -83,16 +91,26 @@ final class DiviOutlineAbility implements Ability 'children' => ['type' => 'integer'], 'editable' => ['type' => 'boolean'], 'depth' => ['type' => 'integer'], + 'truncated' => [ + 'type' => 'boolean', + 'description' => 'Weitere Kinder dieses Knotens wurden wegen "limit" abgeschnitten.', + ], ], 'required' => [ 'id', 'type', 'label', 'excerpt', 'info', 'presets', 'children', 'editable', 'depth', + 'truncated', ], ], ], + 'truncated' => [ + 'type' => 'boolean', + 'description' => 'Irgendwo im Baum wurden Kind-Knoten wegen "limit" abgeschnitten (auch auf ' + . 'oberster Ebene, die kein eigenes "truncated" an einem Knoten zeigen kann).', + ], 'edit_url' => ['type' => 'string'], 'hints' => ['type' => 'array', 'items' => ['type' => 'string']], ], - 'required' => ['post_id', 'tree_rev', 'rev', 'outline', 'nodes', 'hints'], + 'required' => ['post_id', 'tree_rev', 'rev', 'outline', 'nodes', 'truncated', 'hints'], ]; } @@ -129,7 +147,12 @@ final class DiviOutlineAbility implements Ability } $outline = new Outline($this->design->read()->presetNames()); - $rows = $outline->rows($page->index, $rootId, isset($input['depth']) ? (int) $input['depth'] : null); + $rows = $outline->rows( + $page->index, + $rootId, + isset($input['depth']) ? (int) $input['depth'] : null, + isset($input['limit']) ? (int) $input['limit'] : null + ); return [ 'post_id' => (int) $page->post->ID, @@ -139,6 +162,7 @@ final class DiviOutlineAbility implements Ability 'rev' => Rev::ofPost($page->post), 'outline' => $outline->text($rows), 'nodes' => $rows, + 'truncated' => $outline->wasTruncated(), 'edit_url' => $page->editUrl(), 'hints' => $this->hints($page, $rows), ]; diff --git a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php index 15b5090..53be590 100644 --- a/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php +++ b/tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php @@ -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'))); diff --git a/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php new file mode 100644 index 0000000..d2e12d7 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php @@ -0,0 +1,182 @@ +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( + '', + 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), '')); + } + + 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])); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/WarningsTest.php b/tests/Unit/Integrations/Divi/Compose/WarningsTest.php new file mode 100644 index 0000000..898c05e --- /dev/null +++ b/tests/Unit/Integrations/Divi/Compose/WarningsTest.php @@ -0,0 +1,55 @@ +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' => '
x
'], + ['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), [])); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index 6592f16..066025a 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -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'));