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:
@@ -4345,6 +4345,92 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "divi/post-content",
|
||||
"title": "Post Content",
|
||||
"category": "module",
|
||||
"appearance": {
|
||||
"multiple": false,
|
||||
"multipleAliases": [
|
||||
"divi/fullwidth-post-content"
|
||||
],
|
||||
"themeBuilderLayouts": [
|
||||
"body"
|
||||
]
|
||||
},
|
||||
"attributes": {
|
||||
"module": {
|
||||
"settings": {
|
||||
"meta": {
|
||||
"meta": []
|
||||
},
|
||||
"advanced": {
|
||||
"html": []
|
||||
},
|
||||
"decoration": {
|
||||
"animation": [],
|
||||
"attributes": [],
|
||||
"background": [],
|
||||
"bodyFont": [],
|
||||
"border": [],
|
||||
"boxShadow": [],
|
||||
"conditions": [],
|
||||
"disabledOn": [],
|
||||
"filters": [],
|
||||
"interactions": [],
|
||||
"headingFont": [],
|
||||
"overflow": [],
|
||||
"order": [],
|
||||
"position": [],
|
||||
"scroll": [],
|
||||
"sizing": [],
|
||||
"spacing": [],
|
||||
"sticky": [],
|
||||
"transform": [],
|
||||
"zIndex": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"settings": {
|
||||
"decoration": {
|
||||
"border": {
|
||||
"groupType": "group-item",
|
||||
"item": {
|
||||
"groupSlug": "designImage",
|
||||
"priority": 10,
|
||||
"render": true,
|
||||
"component": {
|
||||
"type": "group",
|
||||
"name": "divi/border",
|
||||
"props": {
|
||||
"grouped": false,
|
||||
"fieldLabel": "Image"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"boxShadow": {
|
||||
"groupType": "group-item",
|
||||
"item": {
|
||||
"groupSlug": "designImage",
|
||||
"priority": 15,
|
||||
"render": true,
|
||||
"component": {
|
||||
"type": "group",
|
||||
"name": "divi/box-shadow",
|
||||
"props": {
|
||||
"grouped": false,
|
||||
"fieldLabel": "Image"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaults": {
|
||||
@@ -4887,6 +4973,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"divi/post-content": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ if (! class_exists(ModuleRegistration::class)) {
|
||||
|
||||
$wanted = [
|
||||
'section', 'row', 'column', 'text', 'heading', 'button', 'image', 'blurb', 'code', 'accordion',
|
||||
'accordion-item', 'fullwidth-header', 'group', 'contact-form', 'contact-field',
|
||||
'accordion-item', 'fullwidth-header', 'group', 'contact-form', 'contact-field', 'post-content',
|
||||
];
|
||||
$all = ModuleRegistration::get_all_core_modules_metadata();
|
||||
$metadata = [];
|
||||
@@ -40,6 +40,7 @@ foreach ($wanted as $key) {
|
||||
'category' => $module['category'] ?? '',
|
||||
'childrenName' => $module['childrenName'] ?? null,
|
||||
'childModuleName' => $module['childModuleName'] ?? null,
|
||||
'appearance' => $module['appearance'] ?? null,
|
||||
'attributes' => $attributes,
|
||||
], static fn ($value): bool => $value !== null);
|
||||
$defaults[$module['name']] = ModuleRegistration::get_default_attrs($module['name'], 'default');
|
||||
|
||||
@@ -12,6 +12,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||||
use Mockery;
|
||||
use Throwable;
|
||||
use WP_Post;
|
||||
@@ -100,7 +101,8 @@ final class DiviWriterFixture
|
||||
array &$failures = [],
|
||||
bool $unfiltered = false,
|
||||
?DiviPageLoader $loader = null,
|
||||
?GlobalEdits $globals = null
|
||||
?GlobalEdits $globals = null,
|
||||
?ThemeBuilderEdits $themeBuilder = null
|
||||
): DiviWriter {
|
||||
$design = self::designReader();
|
||||
|
||||
@@ -110,7 +112,8 @@ final class DiviWriterFixture
|
||||
self::log(),
|
||||
$design,
|
||||
self::composition($design, $unfiltered),
|
||||
$globals
|
||||
$globals,
|
||||
$themeBuilder
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Support;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||||
@@ -146,6 +148,12 @@ final class ThemeBuilderFixtures
|
||||
);
|
||||
}
|
||||
|
||||
/** A loader with the library access of LibraryFixtures and $access (default: full TB rights, empty live list). */
|
||||
public static function loader(?ThemeBuilderAccess $access = null): DiviPageLoader
|
||||
{
|
||||
return new DiviPageLoader(DiviFixtures::schema(), new Parser(), LibraryFixtures::access(), $access ?? self::access());
|
||||
}
|
||||
|
||||
/**
|
||||
* An excerpt of et_theme_builder_get_flat_template_settings_options() as the adapter returns it
|
||||
* (live DDEV dump 2026-09-27 plus the Pods type lit_person).
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||||
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -76,4 +78,19 @@ final class StructureRulesTest extends TestCase
|
||||
self::assertFalse($rules->isPlainModule('divi/accordion-item'));
|
||||
self::assertFalse($rules->isPlainModule('divi/row'));
|
||||
}
|
||||
|
||||
/** F22: Divi renders divi/post-content only in body layouts (appearance.themeBuilderLayouts). */
|
||||
public function testModulesLimitedToThemeBuilderAreas(): void
|
||||
{
|
||||
$catalog = DiviFixtures::catalog();
|
||||
$column = Block::create('divi/column');
|
||||
|
||||
self::assertNull((new StructureRules($catalog, 'body'))->violation($column, 'divi/post-content'));
|
||||
self::assertSame(
|
||||
'divi/post-content zeigt Divi im Bereich „header“ nicht (nur body)',
|
||||
(new StructureRules($catalog, 'header'))->violation($column, 'divi/post-content')
|
||||
);
|
||||
self::assertNull((new StructureRules($catalog))->violation($column, 'divi/post-content'), 'pages: Divi decides');
|
||||
self::assertNull((new StructureRules($catalog, 'footer'))->violation($column, 'divi/text'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
||||
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TreeEditorTest extends TestCase
|
||||
@@ -220,4 +222,26 @@ final class TreeEditorTest extends TestCase
|
||||
$editor->insert($editor->position($index, []), [GlobalInstance::of(Block::create('divi/text'), 1059)])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* F22 for stored nodes (node-copy, library-insert, duplicate): a copied section that holds divi/post-content
|
||||
* is refused outside a body layout, however deep the module sits.
|
||||
*/
|
||||
public function testInsertChecksTheThemeBuilderAreaOfNestedModules(): void
|
||||
{
|
||||
$body = (new Parser())->parse(DiviFixtures::markup('stage3/tb-body--body-mit-dynamic-content.html'));
|
||||
$section = NodeIndex::build($body)->find('s0')?->copy();
|
||||
self::assertInstanceOf(Block::class, $section);
|
||||
|
||||
$header = (new Parser())->parse(DiviFixtures::markup('stage3/tb-header--globaler-header.html'));
|
||||
$headerIndex = NodeIndex::build($header);
|
||||
$editor = new TreeEditor(new StructureRules(DiviFixtures::catalog(), 'header'));
|
||||
self::assertSame(
|
||||
'divi/post-content zeigt Divi im Bereich „header“ nicht (nur body)',
|
||||
$editor->insert($editor->position($headerIndex, []), [$section->copy()])
|
||||
);
|
||||
|
||||
$bodyEditor = new TreeEditor(new StructureRules(DiviFixtures::catalog(), 'body'));
|
||||
self::assertNull($bodyEditor->insert($bodyEditor->position(NodeIndex::build($body), []), [$section->copy()]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||
|
||||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
@@ -159,4 +160,37 @@ final class DiviPageLoaderTest extends TestCase
|
||||
|
||||
self::assertToolError(LibraryFixtures::loader($hidden)->load(1058, false), 'not_found');
|
||||
}
|
||||
|
||||
public function testThemeBuilderLayoutsNeedTheThemeBuilderRole(): void
|
||||
{
|
||||
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
|
||||
LibraryFixtures::given([$body]);
|
||||
|
||||
$page = ThemeBuilderFixtures::loader()->load(1062, true);
|
||||
self::assertInstanceOf(DiviPage::class, $page);
|
||||
self::assertTrue($page->isThemeBuilderLayout());
|
||||
self::assertFalse($page->isLibraryItem());
|
||||
self::assertSame('body', $page->themeBuilderArea());
|
||||
self::assertNull(ThemeBuilderFixtures::loader()->writable($page), 'no Divi-enabled check for TB layouts');
|
||||
|
||||
self::assertToolError(
|
||||
ThemeBuilderFixtures::loader(ThemeBuilderFixtures::access(['theme_builder']))->load(1062, false),
|
||||
'not_found'
|
||||
);
|
||||
self::assertToolError(
|
||||
ThemeBuilderFixtures::loader(ThemeBuilderFixtures::access([], true, null, false))->load(1062, false),
|
||||
'not_found'
|
||||
);
|
||||
self::assertToolError(
|
||||
ThemeBuilderFixtures::loader(ThemeBuilderFixtures::access([], false))->load(1062, true),
|
||||
'forbidden'
|
||||
);
|
||||
// Without a Theme Builder access the layout is no managed content (PostGuard; ContentSchema::EXCLUDED).
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('isManaged')->with('et_body_layout')->andReturn(false);
|
||||
self::assertToolError(
|
||||
(new DiviPageLoader($schema, new Parser(), LibraryFixtures::access()))->load(1062, false),
|
||||
'not_found'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SaveScopeTest extends TestCase
|
||||
{
|
||||
/** Finding 4: TB layouts carry Divi's flags after a builder save; a layout change shows site-wide (§5.6). */
|
||||
public function testThemeBuilderLayoutsGetFlagsAndClearEveryPage(): void
|
||||
{
|
||||
self::assertTrue(SaveScope::ThemeBuilderLayout->setsFlags());
|
||||
self::assertTrue(SaveScope::ThemeBuilderLayout->clearsSiteCss());
|
||||
self::assertTrue(SaveScope::Page->setsFlags());
|
||||
self::assertFalse(SaveScope::Page->clearsSiteCss());
|
||||
self::assertFalse(SaveScope::LibraryItem->setsFlags());
|
||||
}
|
||||
}
|
||||
@@ -239,4 +239,12 @@ final class ModuleCatalogTest extends TestCase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testThemeBuilderAreasFromDivisAppearance(): void
|
||||
{
|
||||
$catalog = DiviFixtures::catalog();
|
||||
|
||||
self::assertSame(['body'], $catalog->themeBuilderAreas('divi/post-content'));
|
||||
self::assertNull($catalog->themeBuilderAreas('divi/text'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user