*/ private array $registryCalls = []; private function writer(DiviSaver $saver, ChangeLog $log): DiviWriter { $design = DiviWriterFixture::designReader(); $registry = new DynamicContentMap(function (int $postId): array { $this->registryCalls[] = $postId; return DiviFixtures::dynamicContentRegistry(); }); return new DiviWriter( new DiviPageLoader(DiviFixtures::schema()), $saver, $log, $design, DiviWriterFixture::composition($design, false, $registry) ); } public function testPageChecksTheTreeRevLock(): void { $content = DiviFixtures::markup('synthetic-basic.html'); DiviFixtures::givenPost($content); $writer = $this->writer(Mockery::mock(DiviSaver::class), FakeChangeLog::forbidden()); self::assertInstanceOf(DiviPage::class, $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)])); self::assertToolError($writer->page(['post_id' => 7]), 'rev_mismatch'); $stale = $writer->page(['post_id' => 7, 'tree_rev' => Rev::ofPost(DiviFixtures::givenPost($content))]); self::assertToolError($stale, 'rev_mismatch', 'the post rev is no tree_rev'); self::assertStringContainsString('nichts wurde', $stale->get_error_message()); } public function testPageRefusesWhatIsNotWritable(): void { $content = DiviFixtures::markup('synthetic-basic.html'); DiviFixtures::givenPost($content); $writer = new DiviWriter( new DiviPageLoader(DiviFixtures::schema(false)), Mockery::mock(DiviSaver::class), FakeChangeLog::forbidden(), Mockery::mock(DesignSystemReader::class), Mockery::mock(DiviComposition::class) ); self::assertToolError($writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]), 'unsupported'); } public function testSectionLabelOfANode(): void { DiviFixtures::givenPost(DiviFixtures::markup('synthetic-basic.html')); $page = (new DiviPageLoader(DiviFixtures::schema()))->load(7, true); self::assertSame('Hero', $page->index->sectionLabel($page->index->find('s0.r0.c1'))); self::assertSame('', $page->index->sectionLabel(null)); } public function testCompositionIsBoundToThePage(): void { $content = DiviFixtures::markup('synthetic-basic.html'); DiviFixtures::givenPost($content); $writer = $this->writer(Mockery::mock(DiviSaver::class), FakeChangeLog::forbidden()); $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); $writer->composition($page)->references(); self::assertSame([7], $this->registryCalls, 'the live dynamic content map of post 7 (ruling S22)'); } public function testUnchangedTreeIsNotSaved(): void { $content = DiviFixtures::markup('synthetic-basic.html'); DiviFixtures::givenPost($content); $saver = Mockery::mock(DiviSaver::class); $saver->expects('save')->never(); $writer = $this->writer($saver, FakeChangeLog::forbidden()); $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); $result = $writer->save('lit/divi-update', $page, new DiviChange('nichts'), []); self::assertSame(Rev::of($content), $result['tree_rev']); self::assertStringStartsWith('Keine Änderung', $result['summary']); // Review m-1: the same result shape as a save, URLs included. self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']); self::assertSame('https://kunde.test/?page_id=7&preview=true', $result['preview_url']); } /** Review m-7: a deleted whole section has no section left to outline (its ID now names the next one). */ public function testDeletingAWholeSectionOutlinesNothing(): void { $content = DiviFixtures::markup('synthetic-basic.html'); DiviFixtures::givenPost($content); $saver = Mockery::mock(DiviSaver::class); $saver->allows('save')->andReturnUsing(static function (WP_Post $post, string $new): array { DiviFixtures::givenPost($new); return ['revision_id' => 811, 'warnings' => []]; }); $input = ['post_id' => 7, 'tree_rev' => Rev::of($content)]; Functions\when('do_action')->justReturn(null); Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); $writer = $this->writer($saver, new FakeChangeLog(55)); $page = $writer->page($input); $section = $page->index->find('s0'); $page->document->children()[0]->removeChild($section); $result = $writer->save('lit/divi-delete', $page, (new DiviChange('gelöscht'))->removed('s0', $section), $input); self::assertNull($result['outline']); self::assertSame([['s0', 'divi/section', 'deleted']], array_map( static fn (array $c): array => [$c['id'], $c['type'], $c['action']], $result['changed'] )); } public function testSavesAndReportsChangedNodesOutlineAndTheNewTreeRev(): void { $content = DiviFixtures::markup('synthetic-basic.html'); DiviFixtures::givenPost($content); $stored = null; $saver = Mockery::mock(DiviSaver::class); $saver->expects('save')->andReturnUsing(static function (WP_Post $post, string $new) use (&$stored): array { $stored = $new; DiviFixtures::givenPost($new); return ['revision_id' => 811, 'warnings' => ['vom Speichern']]; }); $log = new FakeChangeLog(55); $input = ['post_id' => 7, 'tree_rev' => Rev::of($content)]; Functions\expect('do_action')->once()->with('lit_mcp_after_save', 7, 'lit/divi-update', $input); Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); $writer = $this->writer($saver, $log); $page = $writer->page($input); $button = $page->index->find('s0.r0.c1.m2'); $removed = $page->index->find('s1'); $button->setAttrs(AttrMap::set($button->attrs(), 'button.innerContent.desktop.value.text', 'Neu')); $page->document->children()[0]->removeChild($removed); $change = (new DiviChange('Button geändert')) ->record($button, 'updated') ->removed('s1', $removed) ->warning('eigene Warnung'); $result = $writer->save('lit/divi-update', $page, $change, $input); self::assertIsString($stored); self::assertStringNotContainsString('wp:divi/blurb', $stored); self::assertSame(Rev::of($stored), $result['tree_rev']); self::assertSame(811, $result['revision_id']); self::assertSame(55, $result['log_id']); self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']); self::assertSame('https://kunde.test/?page_id=7&preview=true', $result['preview_url']); self::assertSame('Button geändert (Status unverändert: draft).', $result['summary']); self::assertSame( [['s0.r0.c1.m2', 'divi/button', 'updated'], ['s1', 'divi/section', 'deleted']], array_map(static fn (array $c): array => [$c['id'], $c['type'], $c['action']], $result['changed']) ); self::assertStringContainsString('Hero', (string) $result['outline']); self::assertStringNotContainsString('Leistungen', (string) $result['outline'], 'only affected sections'); self::assertContains('eigene Warnung', $result['warnings']); self::assertContains('vom Speichern', $result['warnings']); self::assertSame(Rev::ofPost(DiviFixtures::givenPost($stored)), $result['rev']); $log->assertRecorded([ 'ability' => 'lit/divi-update', 'object_type' => 'page', 'object_id' => '7', 'summary' => 'Divi "Start": Button geändert', 'revision_id' => 811, ]); } public function testFailedRollbackIsLoggedAsPartial(): void { $content = DiviFixtures::markup('synthetic-basic.html'); DiviFixtures::givenPost($content); $saver = Mockery::mock(DiviSaver::class); $saver->allows('save')->andThrow(WriteFailed::partial(DiviSaver::REASON, postId: 7, revisionId: 811)); $log = FakeChangeLog::partialsOnly(); $writer = $this->writer($saver, $log); $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); $page->document->children()[0]->removeChild($page->index->find('s1')); try { $writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); } $log->assertPartials([ 'ability' => 'lit/divi-delete', 'object_type' => 'page', 'object_id' => '7', 'summary' => 'Divi "Start": Änderung nur teilweise zurückgenommen', 'revision_id' => 811, ]); } public function testCleanFailureIsNotLogged(): void { $content = DiviFixtures::markup('synthetic-basic.html'); DiviFixtures::givenPost($content); $saver = Mockery::mock(DiviSaver::class); $saver->allows('save')->andThrow(WriteFailed::rolledBack(DiviSaver::REASON, postId: 7, revisionId: 811)); $log = FakeChangeLog::forbidden(); $writer = $this->writer($saver, $log); $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); $page->document->children()[0]->removeChild($page->index->find('s1')); try { $writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); } $log->assertRecorded(); $log->assertPartials(); } /** Spec §5.5: the writer adds the similar-section warnings to every write result. */ public function testWriteResultsCarryTheSimilarSectionWarning(): void { $content = DiviFixtures::markup('synthetic-basic.html'); DiviFixtures::givenPost($content); Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); $similar = Mockery::mock(SimilarSections::class); $similar->allows('warnings')->andReturn(['Ein gleich aufgebauter Abschnitt liegt in der Divi-Bibliothek.']); $saved = []; $failures = []; $writer = DiviWriterFixture::writer($saved, $failures, false, null, null, null, $similar); $result = (new DiviInsertAbility($writer))->execute([ 'post_id' => 7, 'tree_rev' => \LIT\MCP\Abilities\Rev::of($content), 'nodes' => [['type' => 'section', 'modules' => [['type' => 'text', 'content' => 'Neu']]]], ]); self::assertContains('Ein gleich aufgebauter Abschnitt liegt in der Divi-Bibliothek.', $result['warnings']); } /** What lit/library-insert inserts comes from the library – no advice to use the library. */ public function testLibraryInsertIsNotAskedForTheSimilarSectionWarning(): void { $content = DiviFixtures::markup('synthetic-basic.html'); DiviFixtures::givenPost($content); Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); Functions\when('do_action')->justReturn(null); $similar = Mockery::mock(SimilarSections::class); $similar->expects('warnings')->never(); $saved = []; $failures = []; $writer = DiviWriterFixture::writer($saved, $failures, false, null, null, null, $similar); $input = ['post_id' => 7, 'tree_rev' => Rev::of($content)]; $page = $writer->page($input); $section = $page->index->find('s1')->copy(); $page->document->children()[0]->removeChild($page->index->find('s1')); $change = (new DiviChange('eingefügt'))->record($section, 'inserted'); $result = $writer->save('lit/library-insert', $page, $change, $input); self::assertSame(811, $result['revision_id']); } }