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,187 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
final class ThemeBuilderEditsTest extends TestCase
{
private int $backups = 0;
private bool $trash = true;
protected function setUp(): void
{
parent::setUp();
Functions\when('wp_revisions_enabled')->justReturn(true);
Functions\when('current_user_can')->justReturn(true);
}
private static function page(int $id, string $area): DiviPage
{
$post = ThemeBuilderFixtures::layout($id);
$document = (new Parser())->parse((string) $post->post_content);
return new DiviPage($post, $document, NodeIndex::build($document), false, $area);
}
/** @param list<Template> $templates */
private static function edits(array $templates): ThemeBuilderEdits
{
return new ThemeBuilderEdits(ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates)));
}
/**
* With a recording backup (ID 1071) and the trash switch of $this->trash.
*
* @param list<Template> $templates
*/
private function recording(array $templates): ThemeBuilderEdits
{
return new ThemeBuilderEdits(
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates)),
new VisibleEdits(
function (WP_Post $post): int {
++$this->backups;
return 1071;
},
fn (): bool => $this->trash
)
);
}
public function testLiveLayoutNeedsConfirmAndNamesTheTemplates(): void
{
$edits = self::edits([ThemeBuilderFixtures::template(1066, ['enabled' => true])]);
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$dry = $edits->prepare($page, []);
self::assertToolError($dry, 'confirm_required');
self::assertStringContainsString('sichtbar', self::clientError($dry)['message']);
self::assertSame(
[['id' => 1066, 'title' => '[Fixture] Vorlage Globale Instanzen', 'use_on' => ['singular:post_type:page:id:1053']]],
$dry->get_error_data()['templates']
);
$confirmed = $edits->prepare($page, ['confirm' => true]);
self::assertSame(SaveScope::ThemeBuilderLayout, $confirmed['scope']);
self::assertCount(1, $confirmed['details']['templates']);
}
/** The global header: the default template shows it once it is enabled. */
public function testTheDefaultTemplatesHeaderIsLiveWhenItIsEnabled(): void
{
$page = self::page(ThemeBuilderFixtures::HEADER, 'header');
self::assertSame([], self::edits([ThemeBuilderFixtures::template(1065)])->showing($page));
self::assertCount(1, self::edits([ThemeBuilderFixtures::template(1065, ['enabled' => true])])->showing($page));
}
public function testLayoutsOfDisabledOrDetachedTemplatesSaveAtOnce(): void
{
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$prepared = self::edits([
ThemeBuilderFixtures::template(1066),
ThemeBuilderFixtures::template(1067, ['enabled' => true, 'attached' => false]),
])->prepare($page, []);
self::assertSame(SaveScope::ThemeBuilderLayout, $prepared['scope']);
self::assertSame(['templates' => []], $prepared['details']);
}
/** Ruling Q8: attached and enabled is live, whatever the template's post status (fail closed). */
public function testATrashedButEnabledTemplateStillNeedsConfirm(): void
{
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$edits = self::edits([ThemeBuilderFixtures::template(1066, ['enabled' => true, 'status' => 'trash'])]);
self::assertToolError($edits->prepare($page, []), 'confirm_required');
}
public function testNoticeExtendsAToolsOwnConfirm(): void
{
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$own = Errors::confirmRequired('3 Knoten löschen? Nichts wurde gelöscht.', ['ids' => ['s0']]);
$merged = self::edits([ThemeBuilderFixtures::template(1066, ['enabled' => true])])->notice($page, $own);
self::assertToolError($merged, 'confirm_required');
self::assertStringContainsString('3 Knoten löschen?', self::clientError($merged)['message']);
self::assertStringContainsString('sichtbar', self::clientError($merged)['message']);
self::assertSame(['s0'], $merged->get_error_data()['ids']);
self::assertCount(1, $merged->get_error_data()['templates']);
self::assertSame($own, self::edits([])->notice($page, $own));
}
/** Ruling Q2: a confirmed live edit gets a backup when revisions are off, like a global entry. */
public function testWithoutRevisionsAConfirmedLiveEditMakesABackupFirst(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$edits = $this->recording([ThemeBuilderFixtures::template(1066, ['enabled' => true])]);
self::assertToolError($edits->prepare($page, []), 'confirm_required');
self::assertSame(0, $this->backups, 'no backup in the dry run');
$confirmed = $edits->prepare($page, ['confirm' => true]);
self::assertSame(1071, $confirmed['details']['backup_id']);
self::assertSame(1, $this->backups);
}
public function testWithRevisionsNoBackupIsMade(): void
{
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$confirmed = $this->recording([ThemeBuilderFixtures::template(1066, ['enabled' => true])])
->prepare($page, ['confirm' => true]);
self::assertArrayNotHasKey('backup_id', $confirmed['details']);
self::assertSame(0, $this->backups);
}
/** A layout nobody sees needs no backup (like a plain library entry): the revision, if any, is enough. */
public function testALayoutNobodySeesGetsNoBackup(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
$prepared = $this->recording([ThemeBuilderFixtures::template(1066)])
->prepare(self::page(ThemeBuilderFixtures::BODY, 'body'), []);
self::assertSame(['templates' => []], $prepared['details']);
self::assertSame(0, $this->backups);
}
/** Ruling P9 via Q2: the backup must be faithful (unfiltered_html) and must survive (trash). */
public function testWithoutRevisionsUnfaithfulOrLostBackupsAreRefused(): void
{
Functions\when('wp_revisions_enabled')->justReturn(false);
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
$live = [ThemeBuilderFixtures::template(1066, ['enabled' => true])];
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html');
$filtered = $this->recording($live)->prepare($page, ['confirm' => true]);
self::assertToolError($filtered, 'unsupported');
self::assertStringContainsString('Theme-Builder-Layout 1062', $filtered->get_error_message());
self::assertStringContainsString('Theme Builder', (string) self::clientError($filtered)['hint']);
Functions\when('current_user_can')->justReturn(true);
$this->trash = false;
$trash = $this->recording($live)->prepare($page, ['confirm' => true]);
self::assertToolError($trash, 'unsupported');
self::assertStringContainsString('Papierkorb', $trash->get_error_message());
self::assertSame(0, $this->backups);
}
}