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')); } /** 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])); } }