diff --git a/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php index d7af5b3..c0da09e 100644 --- a/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php +++ b/tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php @@ -126,6 +126,27 @@ final class TreeEditorTest extends TestCase 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(); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviDeleteAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviDeleteAbilityTest.php new file mode 100644 index 0000000..0685fcc --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviDeleteAbilityTest.php @@ -0,0 +1,105 @@ +'; + self::assertStringContainsString($buttonBytes, $this->original, 'precondition: exact bytes as expected'); + + $result = $this->delete()->execute([ + 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s0.r0.c1.m2'], 'confirm' => true, + ]); + + self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/button', 'action' => 'deleted', 'label' => 'Hero – Button']], $result['changed']); + self::assertSame(str_replace($buttonBytes, '', $this->original), $this->saved[0]); + self::assertSame(811, $result['revision_id'], 'the restore-point revision'); + self::assertStringContainsString('Hero', (string) $result['outline'], 'the outline is embedded in the result'); + } + + /** An opaque/global module is removed as a whole – it never gets descended into. */ + public function testDeleteRemovesAnOpaqueGlobalModuleAsAWhole(): void + { + $mixed = DiviFixtures::markup('synthetic-mixed.html'); + $this->post->post_content = $mixed; + $globalRow = ''; + self::assertStringContainsString($globalRow, $mixed, 'precondition: exact bytes as expected'); + + $result = $this->delete()->execute([ + 'post_id' => 7, 'tree_rev' => Rev::of($mixed), 'ids' => ['s1.g1'], 'confirm' => true, + ]); + + self::assertSame([['id' => 's1.g1', 'type' => 'divi/row', 'action' => 'deleted', 'label' => 'Globale Zeile']], $result['changed']); + self::assertSame(str_replace($globalRow, '', $mixed), $this->saved[0]); + } + + public function testDeletingTheLastChildLeavesAValidTree(): void + { + $mixed = DiviFixtures::markup('synthetic-mixed.html'); + $this->post->post_content = $mixed; + + $result = $this->delete()->execute([ + 'post_id' => 7, 'tree_rev' => Rev::of($mixed), 'ids' => ['s0.c1.r0.c0.m0'], 'confirm' => true, + ]); + + self::assertTrue($result['ok'], (string) json_encode($result)); + $index = NodeIndex::build((new Parser())->parse($this->saved[0])); + $emptyColumn = $index->find('s0.c1.r0.c0'); + self::assertNotNull($emptyColumn, 'the now-empty column-inner is still a valid, findable node'); + self::assertSame([], $emptyColumn->children()); + } + + public function testDeleteUnknownIdReturnsNotFound(): void + { + $result = $this->delete()->execute([ + 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s9'], 'confirm' => true, + ]); + + self::assertToolError($result, 'not_found'); + self::assertSame('not_found', self::clientError($result)['code']); + self::assertSame([], $this->saved); + } + + /** Same annotations as the existing destructive tools (e.g. MediaDeleteAbility). */ + public function testDeleteAnnotationsMatchExistingDestructiveTools(): void + { + self::assertSame( + ['readonly' => false, 'destructive' => true, 'idempotent' => false], + $this->delete()->annotations() + ); + } + + /** Ruling S14 (Task 7): the deleted node's own confirm question, unaffected by other tests. */ + public function testDeleteAsksBeforeRemovingNestedNodesOnlyOnce(): void + { + $duplicated = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's1']); + self::assertSame('s2', $duplicated['changed'][0]['id']); + + $delete = $this->delete(); + $ask = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2', 's2.r0']]); + self::assertToolError($ask, 'confirm_required'); + self::assertSame( + '1 Divi-Knoten würden samt Inhalt entfernt: s2 section "Leistungen (Kopie)" (1 Kind-Knoten).', + self::clientError($ask)['message'] + ); + + $deleted = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2'], 'confirm' => true]); + self::assertSame([['id' => 's2', 'type' => 'divi/section', 'action' => 'deleted', 'label' => 'Leistungen (Kopie)']], $deleted['changed']); + self::assertSame($this->original, $this->saved[1], 'deleting the copy restores the exact bytes'); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviDuplicateNodeAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviDuplicateNodeAbilityTest.php new file mode 100644 index 0000000..d044f58 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviDuplicateNodeAbilityTest.php @@ -0,0 +1,114 @@ +'; + $sectionClose = ''; + $start = strpos($this->original, $sectionOpen) + strlen($sectionOpen); + $end = strpos($this->original, $sectionClose, $start); + $rowBytes = substr($this->original, $start, $end - $start); + $original = $sectionOpen . $rowBytes . $sectionClose; + self::assertStringContainsString($original, $this->original, 'precondition: exact bytes as expected'); + + $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's1']); + + self::assertSame('s2', $result['changed'][0]['id']); + self::assertSame('Leistungen (Kopie)', $result['changed'][0]['label']); + self::assertStringContainsString('Leistungen', (string) $result['outline'], 'the outline is embedded in the result'); + + $copyOpen = str_replace('"Leistungen"', '"Leistungen (Kopie)"', $sectionOpen); + $copy = $copyOpen . $rowBytes . $sectionClose; + self::assertSame( + str_replace($original, $original . $copy, $this->original), + $this->saved[0], + 'copy directly after the original, byte-identical child content, nothing else changes' + ); + } + + /** A global module copy stays a byte-identical reference (read-only nodes move/duplicate as a whole). */ + public function testDuplicateGlobalModuleCopyIsAByteIdenticalReference(): void + { + $mixed = DiviFixtures::markup('synthetic-mixed.html'); + $this->post->post_content = $mixed; + $globalRow = ''; + self::assertStringContainsString($globalRow, $mixed, 'precondition: exact bytes as expected'); + + $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($mixed), 'id' => 's1.g1']); + + self::assertSame('s1.g2', $result['changed'][0]['id']); + self::assertSame('divi/row', $result['changed'][0]['type']); + self::assertSame('Globale Zeile', $result['changed'][0]['label'], 'opaque copies keep their label unchanged'); + self::assertSame(str_replace($globalRow, $globalRow . $globalRow, $mixed), $this->saved[0]); + } + + /** + * Fix round 1 (Important-1): the copy's admin label must be the raw stored desktop value + * plus " (Kopie)" – never NodeSummary::label()'s display-shortened text (80-char limit). + */ + public function testDuplicateCopiesALongAdminLabelVerbatim(): void + { + $long = str_repeat('Ausführliche Beschriftung, ', 4) . 'Ende'; + self::assertGreaterThan(80, strlen($long), 'precondition: longer than the NodeSummary::label() display limit'); + $content = str_replace( + '"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}}', + '"module":{"meta":{"adminLabel":{"desktop":{"value":"' . $long . '"}}}}', + $this->original + ); + self::assertNotSame($content, $this->original, 'precondition: replaced exactly once'); + $this->post->post_content = $content; + + $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'id' => 's0.r0.c1.m2']); + + self::assertTrue($result['ok'], (string) json_encode($result)); + $index = NodeIndex::build((new Parser())->parse($this->saved[0])); + $copy = $index->find('s0.r0.c1.m3'); + self::assertNotNull($copy, 'the duplicate sits right after the original module'); + self::assertSame( + $long . ' (Kopie)', + AttrMap::desktop($copy->attrs(), 'module.meta.adminLabel'), + 'stored verbatim, not shortened/ellipsised for display' + ); + } + + public function testDuplicateCopiesALabelWithNewlinesAndMultipleSpacesVerbatim(): void + { + $messy = "Erste Zeile mit viel Leerraum\nZweite Zeile"; + $encoded = substr((string) json_encode($messy, JSON_UNESCAPED_UNICODE), 1, -1); + $content = str_replace( + '"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}}', + '"module":{"meta":{"adminLabel":{"desktop":{"value":"' . $encoded . '"}}}}', + $this->original + ); + self::assertNotSame($content, $this->original, 'precondition: replaced exactly once'); + $this->post->post_content = $content; + + $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'id' => 's0.r0.c1.m2']); + + self::assertTrue($result['ok'], (string) json_encode($result)); + $index = NodeIndex::build((new Parser())->parse($this->saved[0])); + $copy = $index->find('s0.r0.c1.m3'); + self::assertNotNull($copy, 'the duplicate sits right after the original module'); + self::assertSame( + $messy . ' (Kopie)', + AttrMap::desktop($copy->attrs(), 'module.meta.adminLabel'), + 'whitespace/newlines kept verbatim, not collapsed' + ); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviInsertAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviInsertAbilityTest.php new file mode 100644 index 0000000..705c674 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviInsertAbilityTest.php @@ -0,0 +1,122 @@ +insert()->execute([ + 'post_id' => 7, + 'tree_rev' => $this->treeRev(), + 'after' => 's0.r0.c1.m1', + 'nodes' => [['type' => 'text', 'content' => '
Neu
']], + ]); + + self::assertTrue($result['ok'], (string) json_encode($result)); + self::assertCount(1, $this->saved); + self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/text', 'action' => 'inserted', 'label' => 'Hero – Text']], $result['changed']); + self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']); + self::assertSame(Rev::ofPost($this->post), $result['rev']); + self::assertSame(811, $result['revision_id']); + self::assertSame(4711, $result['log_id']); + self::assertStringStartsWith('s0 section "Hero"', (string) $result['outline']); + self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']); + + // Byte identity: only the new block was added, every other byte is unchanged. + $inserted = ''; + self::assertSame(str_replace('' . $inserted . '', $this->saved[0]); + self::assertSame('s0', $result['changed'][0]['id']); + self::assertSame('Start', $result['changed'][0]['label']); + } + + /** Ruling S40: the S38 overlap rule for new nodes too. */ + public function testInsertRefusesShortFormAndAttrsOnTheSamePath(): void + { + $result = $this->insert()->execute([ + 'post_id' => 7, + 'tree_rev' => $this->treeRev(), + 'parent' => 's0.r0.c1', + 'nodes' => [[ + 'type' => 'button', + 'text' => 'Neu', + 'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/neu']]], + ]], + ]); + + self::assertToolError($result, 'validation_failed'); + self::assertArrayHasKey('nodes[0].attrs.button.innerContent', self::clientError($result)['fields']); + self::assertSame([], $this->saved); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviMoveAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviMoveAbilityTest.php new file mode 100644 index 0000000..d7c17f4 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviMoveAbilityTest.php @@ -0,0 +1,77 @@ +move()->execute([ + 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's0.r0.c1.m2', 'parent' => 's1.r0.c0', + ]); + self::assertSame([['id' => 's1.r0.c0.m1', 'type' => 'divi/button', 'action' => 'moved', 'label' => 'Hero – Button']], $moved['changed']); + self::assertStringContainsString('Leistungen', (string) $moved['outline'], 'the outline is embedded in the result'); + + $back = $this->move()->execute([ + 'post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'id' => 's1.r0.c0.m1', 'parent' => 's0.r0.c1', + ]); + self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/button', 'action' => 'moved', 'label' => 'Hero – Button']], $back['changed']); + self::assertSame($this->original, $this->saved[1], 'move there and back restores the exact bytes (S14)'); + } + + /** StructureRules apply on move: a module directly into a section, or into a specialty column. */ + public function testMoveRefusedByStructureRules(): void + { + $mixed = DiviFixtures::markup('synthetic-mixed.html'); + $this->post->post_content = $mixed; + $treeRev = Rev::of($mixed); + + // s1 is a regular section: it holds rows, not modules directly. + $intoSection = $this->move()->execute([ + 'post_id' => 7, 'tree_rev' => $treeRev, 'id' => 's0.c0.m0', 'parent' => 's1', + ]); + self::assertToolError($intoSection, 'validation_failed'); + self::assertStringContainsString('Zeilen (row)', self::clientError($intoSection)['fields']['position']); + + // s0.c1 is a specialty column (specialtyColumns set, holds a row-inner): refuses every child. + $intoSpecialtyColumn = $this->move()->execute([ + 'post_id' => 7, 'tree_rev' => $treeRev, 'id' => 's0.c0.m0', 'parent' => 's0.c1', + ]); + self::assertToolError($intoSpecialtyColumn, 'validation_failed'); + self::assertStringContainsString('Spezialspalten', self::clientError($intoSpecialtyColumn)['fields']['position']); + + self::assertSame([], $this->saved); + self::assertSame($mixed, $this->post->post_content); + } + + public function testMoveIntoItsOwnDescendantIsRefused(): void + { + $result = $this->move()->execute([ + 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's0', 'parent' => 's0.r0.c1', + ]); + + self::assertToolError($result, 'validation_failed'); + self::assertStringContainsString( + 'nicht in sich selbst verschoben', + self::clientError($result)['fields']['position'] + ); + self::assertSame([], $this->saved); + } + + public function testMoveRefusesAStaleTreeRev(): void + { + $result = $this->move()->execute([ + 'post_id' => 7, 'tree_rev' => '000000000000', 'id' => 's0.r0.c1.m2', 'parent' => 's1.r0.c0', + ]); + + self::assertToolError($result, 'rev_mismatch'); + self::assertSame('rev_mismatch', self::clientError($result)['code'], 'the client sees the code without lit_'); + self::assertSame([], $this->saved); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php new file mode 100644 index 0000000..3ddb687 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviUpdateAbilityTest.php @@ -0,0 +1,211 @@ +update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ + ['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']], + ['id' => 's0.r0.c1.m0', 'set' => ['level' => 'h9']], + ['id' => 's0.r0.c1.m9', 'label' => 'x'], + ]]); + self::assertToolError($failed, 'validation_failed'); + $fields = self::clientError($failed)['fields']; + self::assertArrayHasKey('changes[1].set.level', $fields); + self::assertArrayHasKey('changes[2].id', $fields, 'every error of the call in one response (S39)'); + self::assertSame([], $this->saved); + + $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ + ['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']], + ['id' => 's0.r0.c1.m0', 'attrs' => ['title.decoration.font.font' => ['phone' => null]]], + ]]); + + self::assertTrue($result['ok']); + self::assertSame(['s0.r0.c1.m2', 's0.r0.c1.m0'], array_column($result['changed'], 'id')); + self::assertSame(['updated', 'updated'], array_column($result['changed'], 'action')); + self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']); + $index = NodeIndex::build((new Parser())->parse($this->saved[0])); + self::assertSame('Jetzt buchen', $index->find('s0.r0.c1.m2')?->attrs()['button']['innerContent']['desktop']['value']['text']); + self::assertArrayNotHasKey('phone', $index->find('s0.r0.c1.m0')?->attrs()['title']['decoration']['font']['font']); + // Untouched section s1 keeps its exact bytes. + self::assertStringContainsString(substr($this->original, (int) strpos($this->original, ''; - self::assertSame(str_replace('' . $inserted . '', $this->saved[0]); - self::assertSame('s0', $result['changed'][0]['id']); - self::assertSame('Start', $result['changed'][0]['label']); - } - - public function testUpdateChangesSeveralNodesAllOrNothing(): void - { - $failed = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ - ['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']], - ['id' => 's0.r0.c1.m0', 'set' => ['level' => 'h9']], - ['id' => 's0.r0.c1.m9', 'label' => 'x'], - ]]); - self::assertToolError($failed, 'validation_failed'); - $fields = self::clientError($failed)['fields']; - self::assertArrayHasKey('changes[1].set.level', $fields); - self::assertArrayHasKey('changes[2].id', $fields, 'every error of the call in one response (S39)'); - self::assertSame([], $this->saved); - - $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ - ['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Jetzt buchen']], - ['id' => 's0.r0.c1.m0', 'attrs' => ['title.decoration.font.font' => ['phone' => null]]], - ]]); - - self::assertTrue($result['ok']); - self::assertSame(['s0.r0.c1.m2', 's0.r0.c1.m0'], array_column($result['changed'], 'id')); - self::assertSame(['updated', 'updated'], array_column($result['changed'], 'action')); - self::assertSame(Rev::of($this->saved[0]), $result['tree_rev']); - $index = NodeIndex::build((new Parser())->parse($this->saved[0])); - self::assertSame('Jetzt buchen', $index->find('s0.r0.c1.m2')?->attrs()['button']['innerContent']['desktop']['value']['text']); - self::assertArrayNotHasKey('phone', $index->find('s0.r0.c1.m0')?->attrs()['title']['decoration']['font']['font']); - // Untouched section s1 keeps its exact bytes. - self::assertStringContainsString(substr($this->original, (int) strpos($this->original, ''; - self::assertStringContainsString($buttonBytes, $this->original, 'precondition: exact bytes as expected'); - - $result = $this->delete()->execute([ - 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s0.r0.c1.m2'], 'confirm' => true, - ]); - - self::assertSame([['id' => 's0.r0.c1.m2', 'type' => 'divi/button', 'action' => 'deleted', 'label' => 'Hero – Button']], $result['changed']); - self::assertSame(str_replace($buttonBytes, '', $this->original), $this->saved[0]); - self::assertSame(811, $result['revision_id'], 'the restore-point revision'); - self::assertStringContainsString('Hero', (string) $result['outline'], 'the outline is embedded in the result'); - } - - /** An opaque/global module is removed as a whole – it never gets descended into. */ - public function testDeleteRemovesAnOpaqueGlobalModuleAsAWhole(): void - { - $mixed = DiviFixtures::markup('synthetic-mixed.html'); - $this->post->post_content = $mixed; - $globalRow = ''; - self::assertStringContainsString($globalRow, $mixed, 'precondition: exact bytes as expected'); - - $result = $this->delete()->execute([ - 'post_id' => 7, 'tree_rev' => Rev::of($mixed), 'ids' => ['s1.g1'], 'confirm' => true, - ]); - - self::assertSame([['id' => 's1.g1', 'type' => 'divi/row', 'action' => 'deleted', 'label' => 'Globale Zeile']], $result['changed']); - self::assertSame(str_replace($globalRow, '', $mixed), $this->saved[0]); - } - - public function testDeletingTheLastChildLeavesAValidTree(): void - { - $mixed = DiviFixtures::markup('synthetic-mixed.html'); - $this->post->post_content = $mixed; - - $result = $this->delete()->execute([ - 'post_id' => 7, 'tree_rev' => Rev::of($mixed), 'ids' => ['s0.c1.r0.c0.m0'], 'confirm' => true, - ]); - - self::assertTrue($result['ok'], (string) json_encode($result)); - $index = NodeIndex::build((new Parser())->parse($this->saved[0])); - $emptyColumn = $index->find('s0.c1.r0.c0'); - self::assertNotNull($emptyColumn, 'the now-empty column-inner is still a valid, findable node'); - self::assertSame([], $emptyColumn->children()); - } - - public function testDeleteUnknownIdReturnsNotFound(): void - { - $result = $this->delete()->execute([ - 'post_id' => 7, 'tree_rev' => $this->treeRev(), 'ids' => ['s9'], 'confirm' => true, - ]); - - self::assertToolError($result, 'not_found'); - self::assertSame('not_found', self::clientError($result)['code']); - self::assertSame([], $this->saved); - } - - /** Same annotations as the existing destructive tools (e.g. MediaDeleteAbility). */ - public function testDeleteAnnotationsMatchExistingDestructiveTools(): void - { - self::assertSame( - ['readonly' => false, 'destructive' => true, 'idempotent' => false], - $this->delete()->annotations() - ); - } - - /** Ruling S14 (Task 7): the deleted node's own confirm question, unaffected by other tests. */ - public function testDeleteAsksBeforeRemovingNestedNodesOnlyOnce(): void - { - $duplicated = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's1']); - self::assertSame('s2', $duplicated['changed'][0]['id']); - - $delete = $this->delete(); - $ask = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2', 's2.r0']]); - self::assertToolError($ask, 'confirm_required'); - self::assertSame( - '1 Divi-Knoten würden samt Inhalt entfernt: s2 section "Leistungen (Kopie)" (1 Kind-Knoten).', - self::clientError($ask)['message'] - ); - - $deleted = $delete->execute(['post_id' => 7, 'tree_rev' => Rev::of($this->saved[0]), 'ids' => ['s2'], 'confirm' => true]); - self::assertSame([['id' => 's2', 'type' => 'divi/section', 'action' => 'deleted', 'label' => 'Leistungen (Kopie)']], $deleted['changed']); - self::assertSame($this->original, $this->saved[1], 'deleting the copy restores the exact bytes'); - } - - /** The copy sits directly after the original; its own child content is byte-identical, nothing else changes. */ - public function testDuplicateInsertsTheCopyDirectlyAfterTheOriginal(): void - { - $sectionOpen = ''; - $sectionClose = ''; - $start = strpos($this->original, $sectionOpen) + strlen($sectionOpen); - $end = strpos($this->original, $sectionClose, $start); - $rowBytes = substr($this->original, $start, $end - $start); - $original = $sectionOpen . $rowBytes . $sectionClose; - self::assertStringContainsString($original, $this->original, 'precondition: exact bytes as expected'); - - $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'id' => 's1']); - - self::assertSame('s2', $result['changed'][0]['id']); - self::assertSame('Leistungen (Kopie)', $result['changed'][0]['label']); - self::assertStringContainsString('Leistungen', (string) $result['outline'], 'the outline is embedded in the result'); - - $copyOpen = str_replace('"Leistungen"', '"Leistungen (Kopie)"', $sectionOpen); - $copy = $copyOpen . $rowBytes . $sectionClose; - self::assertSame( - str_replace($original, $original . $copy, $this->original), - $this->saved[0], - 'copy directly after the original, byte-identical child content, nothing else changes' - ); - } - - /** A global module copy stays a byte-identical reference (read-only nodes move/duplicate as a whole). */ - public function testDuplicateGlobalModuleCopyIsAByteIdenticalReference(): void - { - $mixed = DiviFixtures::markup('synthetic-mixed.html'); - $this->post->post_content = $mixed; - $globalRow = ''; - self::assertStringContainsString($globalRow, $mixed, 'precondition: exact bytes as expected'); - - $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($mixed), 'id' => 's1.g1']); - - self::assertSame('s1.g2', $result['changed'][0]['id']); - self::assertSame('divi/row', $result['changed'][0]['type']); - self::assertSame('Globale Zeile', $result['changed'][0]['label'], 'opaque copies keep their label unchanged'); - self::assertSame(str_replace($globalRow, $globalRow . $globalRow, $mixed), $this->saved[0]); - } - - /** - * Fix round 1 (Important-1): the copy's admin label must be the raw stored desktop value - * plus " (Kopie)" – never NodeSummary::label()'s display-shortened text (80-char limit). - */ - public function testDuplicateCopiesALongAdminLabelVerbatim(): void - { - $long = str_repeat('Ausführliche Beschriftung, ', 4) . 'Ende'; - self::assertGreaterThan(80, strlen($long), 'precondition: longer than the NodeSummary::label() display limit'); - $content = str_replace( - '"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}}', - '"module":{"meta":{"adminLabel":{"desktop":{"value":"' . $long . '"}}}}', - $this->original - ); - self::assertNotSame($content, $this->original, 'precondition: replaced exactly once'); - $this->post->post_content = $content; - - $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'id' => 's0.r0.c1.m2']); - - self::assertTrue($result['ok'], (string) json_encode($result)); - $index = NodeIndex::build((new Parser())->parse($this->saved[0])); - $copy = $index->find('s0.r0.c1.m3'); - self::assertNotNull($copy, 'the duplicate sits right after the original module'); - self::assertSame( - $long . ' (Kopie)', - AttrMap::desktop($copy->attrs(), 'module.meta.adminLabel'), - 'stored verbatim, not shortened/ellipsised for display' - ); - } - - public function testDuplicateCopiesALabelWithNewlinesAndMultipleSpacesVerbatim(): void - { - $messy = "Erste Zeile mit viel Leerraum\nZweite Zeile"; - $encoded = substr((string) json_encode($messy, JSON_UNESCAPED_UNICODE), 1, -1); - $content = str_replace( - '"module":{"meta":{"adminLabel":{"desktop":{"value":"Hero – Button"}}}}', - '"module":{"meta":{"adminLabel":{"desktop":{"value":"' . $encoded . '"}}}}', - $this->original - ); - self::assertNotSame($content, $this->original, 'precondition: replaced exactly once'); - $this->post->post_content = $content; - - $result = $this->duplicate()->execute(['post_id' => 7, 'tree_rev' => Rev::of($content), 'id' => 's0.r0.c1.m2']); - - self::assertTrue($result['ok'], (string) json_encode($result)); - $index = NodeIndex::build((new Parser())->parse($this->saved[0])); - $copy = $index->find('s0.r0.c1.m3'); - self::assertNotNull($copy, 'the duplicate sits right after the original module'); - self::assertSame( - $messy . ' (Kopie)', - AttrMap::desktop($copy->attrs(), 'module.meta.adminLabel'), - 'whitespace/newlines kept verbatim, not collapsed' - ); - } - - /** Permission denied (edit_post false) for all three new abilities. */ - public function testPermissionDeniedWhenNotEditable(): void - { - Functions\when('current_user_can')->justReturn(false); - - foreach ([$this->move(), $this->delete(), $this->duplicate()] as $ability) { - $result = $ability->permission(['post_id' => 7, 'tree_rev' => $this->treeRev()]); - self::assertInstanceOf(WP_Error::class, $result, $ability->name()); - self::assertToolError($result, 'forbidden', $ability->name()); - } - self::assertSame([], $this->saved); - } - - public function testReadOnlyNodesCannotBeUpdated(): void - { - $this->post->post_content = DiviFixtures::markup('synthetic-mixed.html'); - - $result = $this->update()->execute([ - 'post_id' => 7, - 'tree_rev' => Rev::of($this->post->post_content), - 'changes' => [['id' => 's1.g1', 'label' => 'x']], - ]); - - self::assertToolError($result, 'validation_failed'); - self::assertStringContainsString('nur lesbar', $result->get_error_message()); - self::assertSame([], $this->saved); - } - - /** Ruling S14: the same node twice in one call would silently lose the first change. */ - public function testUpdateRefusesADuplicateId(): void - { - $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ - ['id' => 's0.r0.c1.m2', 'set' => ['text' => 'Eins']], - ['id' => 's0.r0.c1.m2', 'set' => ['url' => '/zwei']], - ]]); - - self::assertToolError($result, 'validation_failed'); - self::assertStringContainsString('changes[0]', self::clientError($result)['fields']['changes[1].id']); - self::assertSame([], $this->saved); - } - - /** Ruling S38: set and attrs of one change must not reach the same attribute path. */ - public function testUpdateRefusesSetAndAttrsOnTheSamePath(): void - { - $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [[ - 'id' => 's0.r0.c1.m2', - 'set' => ['text' => 'Neu'], - 'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/neu']]], - ]]]); - - self::assertToolError($result, 'validation_failed'); - self::assertArrayHasKey('changes[0].attrs.button.innerContent', self::clientError($result)['fields']); - self::assertSame([], $this->saved); - } - - /** Ruling S40: the S38 overlap rule for new nodes too. */ - public function testInsertRefusesShortFormAndAttrsOnTheSamePath(): void - { - $result = $this->insert()->execute([ - 'post_id' => 7, - 'tree_rev' => $this->treeRev(), - 'parent' => 's0.r0.c1', - 'nodes' => [[ - 'type' => 'button', - 'text' => 'Neu', - 'attrs' => ['button.innerContent' => ['desktop' => ['linkUrl' => '/neu']]], - ]], - ]); - - self::assertToolError($result, 'validation_failed'); - self::assertArrayHasKey('nodes[0].attrs.button.innerContent', self::clientError($result)['fields']); - self::assertSame([], $this->saved); - } - - /** Ruling S41: a column width change must still fill its row exactly. */ - public function testUpdateChecksTheRowWidths(): void - { - $sizing = static fn (string $width): array => ['module.decoration.sizing' => ['desktop' => ['flexType' => $width]]]; - - $broken = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ - ['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')], - ]]); - self::assertToolError($broken, 'validation_failed'); - self::assertArrayHasKey('changes[0].attrs.module.decoration.sizing', self::clientError($broken)['fields']); - self::assertSame([], $this->saved); - - $result = $this->update()->execute(['post_id' => 7, 'tree_rev' => $this->treeRev(), 'changes' => [ - ['id' => 's0.r0.c0', 'attrs' => $sizing('12_24')], - ['id' => 's0.r0.c1', 'attrs' => $sizing('12_24')], - ]]); - self::assertTrue($result['ok'], (string) json_encode($result)); - self::assertCount(1, $this->saved); - self::assertStringContainsString('"flexType":"12_24"}}}}},"builderVersion":"5.2.1"} -->