feat: Divi tools edit Theme Builder layouts; visible layouts need confirm

TB layouts load through ThemeBuilderAccess (P12), live layouts (attached +
enabled, Q8) need a dry run and confirm (Q9) and get the shared restore point
of VisibleEdits, extracted from GlobalEdits (Q2: backup when revisions are
off, P9 refusals, P14 naming). divi/post-content only in body layouts (F22),
also for nested stored nodes. SaveScope::ThemeBuilderLayout sets Divi flags
and clears site CSS + TB cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 15:45:07 +02:00
co-authored by Claude Opus 5.5
parent 4ff1c1e2a5
commit b1dd35abd8
27 changed files with 1106 additions and 132 deletions
@@ -0,0 +1,195 @@
<?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\Integrations\Divi\Pages\DiviDeleteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Throwable;
use WP_Post;
/** The Divi write tools on Theme Builder layouts (E1, Task 5). */
final class DiviWriterThemeBuilderTest extends TestCase
{
/** @var list<string> */
private array $saved = [];
/** @var list<Throwable> */
private array $failures = [];
private WP_Post $body;
private WP_Post $header;
protected function setUp(): void
{
parent::setUp();
$this->body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
$this->header = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::HEADER);
LibraryFixtures::given([$this->body, $this->header]);
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
Functions\when('wp_revisions_enabled')->justReturn(true);
}
/** @param list<Template> $templates */
private function writer(array $templates, ?VisibleEdits $visible = null): DiviWriter
{
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates));
return DiviWriterFixture::writer(
$this->saved,
$this->failures,
false,
ThemeBuilderFixtures::loader($access),
null,
$visible === null ? new ThemeBuilderEdits($access) : new ThemeBuilderEdits($access, $visible)
);
}
/** @param array<string, mixed> $extra @return array<string, mixed> */
private function insert(WP_Post $post, array $extra): array
{
return array_merge([
'post_id' => (int) $post->ID,
'tree_rev' => Rev::of((string) $post->post_content),
'parent' => 's0.r0.c0',
'nodes' => [['type' => 'text', 'content' => 'Zusatz']],
], $extra);
}
public function testInsertIntoALiveLayoutIsADryRunFirst(): void
{
$ability = new DiviInsertAbility($this->writer([ThemeBuilderFixtures::template(1066, ['enabled' => true])]));
$dry = $ability->execute($this->insert($this->body, []));
self::assertToolError($dry, 'confirm_required');
self::assertSame([], $this->saved);
$result = $ability->execute($this->insert($this->body, ['confirm' => true]));
self::assertTrue($result['ok']);
self::assertCount(1, $this->saved);
self::assertSame(1066, $result['templates'][0]['id']);
}
public function testLayoutOfADisabledTemplateSavesAtOnce(): void
{
$result = (new DiviInsertAbility($this->writer([ThemeBuilderFixtures::template()])))
->execute($this->insert($this->body, []));
self::assertTrue($result['ok']);
self::assertSame([], $result['templates']);
self::assertStringContainsString('Zusatz', $this->saved[0]);
self::assertSame('https://kunde.test/wp-admin/admin.php?page=et_theme_builder', $result['edit_url'] ?? null);
}
public function testDeleteInALiveLayoutAsksOnceForBoth(): void
{
$ability = new DiviDeleteAbility($this->writer([ThemeBuilderFixtures::template(1066, ['enabled' => true])]));
$input = ['post_id' => 1062, 'tree_rev' => Rev::of((string) $this->body->post_content), 'ids' => ['s0.r0.c0.m1']];
$dry = $ability->execute($input);
self::assertToolError($dry, 'confirm_required');
self::assertArrayHasKey('templates', $dry->get_error_data());
$result = $ability->execute($input + ['confirm' => true]);
self::assertTrue($result['ok']);
self::assertCount(1, $this->saved);
}
public function testPostContentOnlyInTheBody(): void
{
$ability = new DiviInsertAbility($this->writer([]));
$postContent = ['nodes' => [['type' => 'post-content']]];
self::assertToolError($ability->execute($this->insert($this->header, $postContent)), 'validation_failed');
self::assertTrue($ability->execute($this->insert($this->body, $postContent))['ok']);
}
/** Ruling Q2 / P14: a failed save of a live layout after its backup names the backup. */
public function testAFailedSaveAfterTheBackupNamesIt(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
$this->failures = [WriteFailed::rolledBack(DiviSaver::REASON, new \RuntimeException('hook'), postId: 1062)];
$backups = 0;
$visible = new VisibleEdits(static function (WP_Post $post) use (&$backups): int {
++$backups;
return 1071;
}, static fn (): bool => true);
$ability = new DiviInsertAbility(
$this->writer([ThemeBuilderFixtures::template(1066, ['enabled' => true])], $visible)
);
try {
$ability->execute($this->insert($this->body, ['confirm' => true]));
self::fail('WriteFailed expected');
} catch (WriteFailed $e) {
$error = $e->toError();
}
self::assertSame(1, $backups);
self::assertSame(1071, $error->get_error_data()['backup_id']);
self::assertStringContainsString('Sicherung 1071 liegt im Papierkorb', $error->get_error_data()['hint']);
self::assertStringContainsString('Theme-Builder-Layouts', $error->get_error_data()['hint']);
self::assertSame([], $this->saved);
}
/** Without a ThemeBuilderEdits (no Theme Builder wiring) a layout is never written. */
public function testWithoutThemeBuilderEditsLayoutsAreNotWritten(): void
{
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([]));
$writer = DiviWriterFixture::writer($this->saved, $this->failures, false, ThemeBuilderFixtures::loader($access));
self::assertToolError((new DiviInsertAbility($writer))->execute($this->insert($this->body, [])), 'unsupported');
self::assertSame([], $this->saved);
}
/** lit/divi-replace-text never sends confirm: a live layout only answers with its templates. */
public function testReplaceTextLeavesALiveLayoutAndNamesItsTemplates(): void
{
Functions\when('get_post_type_object')->justReturn(
(object) ['cap' => (object) ['edit_posts' => 'edit_pages', 'edit_others_posts' => 'edit_others_pages']]
);
$access = ThemeBuilderFixtures::access(
[],
true,
ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065, ['enabled' => true])])
);
$loader = ThemeBuilderFixtures::loader($access);
$writer = DiviWriterFixture::writer($this->saved, $this->failures, false, $loader, null, new ThemeBuilderEdits($access));
$ability = new DiviReplaceTextAbility(DiviFixtures::schema(), $loader, $writer, Mockery::mock(\wpdb::class)->makePartial());
$result = $ability->execute([
'search' => 'Globaler Header der Standard-Vorlage',
'replace' => 'Kopfzeile',
'dry_run' => false,
'confirm' => true,
'targets' => [['post_id' => 1061, 'tree_rev' => Rev::of((string) $this->header->post_content)]],
]);
self::assertIsArray($result, $result instanceof \WP_Error ? $result->get_error_message() : '');
$entry = $result['results'][0];
self::assertFalse($entry['ok']);
self::assertStringContainsString('Theme-Builder-Layout 1061', $entry['error']);
self::assertStringContainsString('lit-divi-update', $entry['error']);
self::assertStringNotContainsString('Globale Bibliothekseinträge', $entry['error']);
self::assertSame(1065, $entry['templates'][0]['id']);
self::assertSame([], $this->saved);
}
}