SimilarSections compares inserted sections (not global instances) with at most 200 editable library section entries by structure fingerprint, read lazily once per request, only while library IDs may be named (P13). lit/library-insert is not asked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
295 lines
13 KiB
PHP
295 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||
use LIT\MCP\Integrations\Divi\Library\SimilarSections;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class DiviWriterTest extends TestCase
|
||
{
|
||
/** @var list<int> */
|
||
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']);
|
||
}
|
||
}
|