feat: lit/library-make-global – node to global module, all or nothing (E10)
- NewLibraryEntry: spec and creation of a new library entry, shared by lit/library-save and lit/library-make-global (NewDraft, P7, StoredNodeGuard) - UsageMatcher::referencesAny() (ruling Q7): a global entry never names any entry in any spelling Divi renders (P23/P24 forms, Divi 4 shortcodes inside Divi 5 values, values casting to 0); used by library-save scope global and make-global - make-global: entry created, node replaced by GlobalInstance::of, page through DiviWriter::commit; refused or rolled-back page deletes the entry, a partial page write keeps it (the page may show it); sources are pages and posts only Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -60,7 +60,13 @@ final class DiviIntegrationTest extends TestCase
|
||||
self::assertSame('Divi-Bibliothek', $library->label());
|
||||
self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests');
|
||||
self::assertSame(
|
||||
['lit/library-list', 'lit/library-save', 'lit/library-insert', 'lit/library-find-similar'],
|
||||
[
|
||||
'lit/library-list',
|
||||
'lit/library-save',
|
||||
'lit/library-insert',
|
||||
'lit/library-find-similar',
|
||||
'lit/library-make-global',
|
||||
],
|
||||
array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities())
|
||||
);
|
||||
foreach ($library->abilities() as $ability) {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
|
||||
use LIT\MCP\Integrations\Divi\Library\LibraryMakeGlobalAbility;
|
||||
use LIT\MCP\Integrations\Divi\Library\NewLibraryEntry;
|
||||
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\SaveScope;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
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;
|
||||
|
||||
final class LibraryMakeGlobalAbilityTest extends TestCase
|
||||
{
|
||||
private WP_Post $page;
|
||||
|
||||
/** @var list<LibraryItemSpec> */
|
||||
private array $specs = [];
|
||||
|
||||
/** @var list<array{int, string, SaveScope}> */
|
||||
private array $entryContents = [];
|
||||
|
||||
/** @var list<string> Page contents DiviWriter saved. */
|
||||
private array $saved = [];
|
||||
|
||||
private FakeChangeLog $log;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
|
||||
LibraryFixtures::given([$this->page]);
|
||||
Functions\when('wp_revisions_enabled')->justReturn(true);
|
||||
$this->log = DiviWriterFixture::log();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $deniedRoles
|
||||
* @param list<Throwable> $pageFailures
|
||||
*/
|
||||
private function ability(
|
||||
array $deniedRoles = [],
|
||||
bool $diviWritable = true,
|
||||
bool $unfiltered = false,
|
||||
array $pageFailures = [],
|
||||
?DiviPageLoader $loader = null,
|
||||
?DiviWriter $writer = null
|
||||
): LibraryMakeGlobalAbility {
|
||||
$store = LibraryFixtures::store();
|
||||
$store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int {
|
||||
$this->specs[] = $spec;
|
||||
|
||||
return 2300;
|
||||
});
|
||||
$saver = Mockery::mock(DiviSaver::class);
|
||||
$saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope): array {
|
||||
$this->entryContents[] = [$id, $content, $scope];
|
||||
|
||||
return [];
|
||||
});
|
||||
$access = LibraryFixtures::access($deniedRoles, true, $store);
|
||||
|
||||
return new LibraryMakeGlobalAbility(
|
||||
$access,
|
||||
$writer ?? DiviWriterFixture::writer(
|
||||
$this->saved,
|
||||
$pageFailures,
|
||||
$unfiltered,
|
||||
$loader ?? LibraryFixtures::loader($access)
|
||||
),
|
||||
new NewLibraryEntry($access, $saver, DiviWriterFixture::modules()),
|
||||
$this->log,
|
||||
static fn (): bool => $diviWritable
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function input(array $extra = []): array
|
||||
{
|
||||
return array_merge(
|
||||
['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'],
|
||||
$extra
|
||||
);
|
||||
}
|
||||
|
||||
public function testReplacesTheNodeByAnInstanceOfTheNewGlobalEntry(): void
|
||||
{
|
||||
$result = $this->ability()->execute($this->input());
|
||||
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertSame(2300, $result['library_id']);
|
||||
self::assertTrue($this->specs[0]->global);
|
||||
self::assertSame(
|
||||
str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')),
|
||||
$this->entryContents[0][1],
|
||||
'the entry holds exactly the node, framed like the builder'
|
||||
);
|
||||
self::assertCount(1, $this->saved);
|
||||
self::assertStringContainsString(
|
||||
'<!-- wp:divi/global-layout {"globalModule":"2300","blockName":"divi/section","localAttrs":',
|
||||
$this->saved[0]
|
||||
);
|
||||
self::assertStringNotContainsString(
|
||||
'<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Abschnitt"',
|
||||
$this->saved[0],
|
||||
'the plain section is gone'
|
||||
);
|
||||
self::assertNotSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, 'lit/library-find-similar')));
|
||||
}
|
||||
|
||||
public function testEditorCannotMakeACodeModuleGlobal(): void
|
||||
{
|
||||
$this->page = LibraryFixtures::page(
|
||||
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||||
. '<!-- wp:divi/code {"content":{"innerContent":{"desktop":{"value":"<b>x</b>"}}}} /-->'
|
||||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
|
||||
);
|
||||
LibraryFixtures::given([$this->page]);
|
||||
|
||||
$result = $this->ability()->execute($this->input(['id' => 's0']));
|
||||
|
||||
self::assertToolError($result, 'validation_failed');
|
||||
self::assertStringContainsString('Code-Modul', implode(' ', self::errorFields($result)));
|
||||
self::assertSame([], $this->specs);
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
/** All or nothing: the page save failing removes the new entry again. */
|
||||
public function testFailedPageSaveDeletesTheNewEntry(): void
|
||||
{
|
||||
Functions\expect('wp_delete_post')->once()->with(2300, true)->andReturn(new WP_Post(['ID' => 2300]));
|
||||
|
||||
try {
|
||||
$this->ability([], true, false, [WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
|
||||
->execute($this->input());
|
||||
self::fail('no WriteFailed');
|
||||
} catch (WriteFailed $e) {
|
||||
self::assertTrue($e->rolledBack);
|
||||
}
|
||||
self::assertCount(1, $this->specs);
|
||||
}
|
||||
|
||||
public function testRefusesNodesThatAreOrHoldGlobalModules(): void
|
||||
{
|
||||
self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed');
|
||||
self::assertToolError($this->ability()->execute($this->input(['id' => 's0'])), 'validation_failed');
|
||||
self::assertSame([], $this->specs);
|
||||
}
|
||||
|
||||
public function testNeedsSaveGlobalAndInsertRightsAndTheDiviGate(): void
|
||||
{
|
||||
self::assertToolError($this->ability(['edit_global_library'])->execute($this->input()), 'forbidden');
|
||||
self::assertToolError($this->ability(['add_library'])->execute($this->input()), 'forbidden');
|
||||
self::assertToolError($this->ability([], false)->execute($this->input()), 'unsupported');
|
||||
self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'alt'])), 'rev_mismatch');
|
||||
self::assertSame([], $this->specs);
|
||||
self::assertFalse($this->ability()->isDirect());
|
||||
}
|
||||
|
||||
public function testLogsTheNewEntry(): void
|
||||
{
|
||||
$this->ability()->execute($this->input());
|
||||
|
||||
self::assertSame('et_pb_layout', $this->log->records[0]['object_type']);
|
||||
self::assertSame('2300', $this->log->records[0]['object_id']);
|
||||
self::assertStringContainsString('global', $this->log->records[0]['summary']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling Q7: a Divi 4 shortcode in a text value renders another entry (or, casting to 0, the newest
|
||||
* layout – the entry being created); such a node never becomes a global entry.
|
||||
*/
|
||||
public function testRefusesAShortcodeReferenceInsideAValue(): void
|
||||
{
|
||||
$quote = chr(92) . '"';
|
||||
foreach (['1058', 'abc'] as $ref) {
|
||||
$this->page = LibraryFixtures::page(
|
||||
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||||
. '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"[et_pb_text global_module='
|
||||
. $quote . $ref . $quote . ']x[/et_pb_text]"}}}} /-->'
|
||||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
|
||||
);
|
||||
LibraryFixtures::given([$this->page]);
|
||||
|
||||
$result = $this->ability([], true, true)->execute($this->input(['id' => 's0']));
|
||||
|
||||
self::assertToolError($result, 'validation_failed');
|
||||
self::assertStringContainsString('global_module', implode(' ', self::errorFields($result)));
|
||||
}
|
||||
self::assertSame([], $this->specs);
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
public function testLibraryEntriesAndThemeBuilderLayoutsAreNoSource(): void
|
||||
{
|
||||
$item = LibraryFixtures::item(1055);
|
||||
LibraryFixtures::given([$item]);
|
||||
$fromLibrary = $this->ability()->execute(
|
||||
['post_id' => 1055, 'tree_rev' => Rev::of((string) $item->post_content), 'id' => 's0', 'title' => 'x']
|
||||
);
|
||||
self::assertToolError($fromLibrary, 'validation_failed');
|
||||
self::assertArrayHasKey('post_id', self::errorFields($fromLibrary));
|
||||
|
||||
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
|
||||
LibraryFixtures::given([$body]);
|
||||
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||||
$loader = new DiviPageLoader(
|
||||
DiviFixtures::schema(),
|
||||
new Parser(),
|
||||
LibraryFixtures::access(),
|
||||
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template()]))
|
||||
);
|
||||
$fromLayout = $this->ability([], true, false, [], $loader)->execute(
|
||||
['post_id' => 1062, 'tree_rev' => Rev::of((string) $body->post_content), 'id' => 's0', 'title' => 'x']
|
||||
);
|
||||
self::assertToolError($fromLayout, 'validation_failed');
|
||||
self::assertStringContainsString('Theme-Builder', $fromLayout->get_error_message());
|
||||
|
||||
self::assertSame([], $this->specs);
|
||||
self::assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
/** A page write that may have been saved keeps the entry – the page may already show it. */
|
||||
public function testAPartialPageWriteKeepsTheEntry(): void
|
||||
{
|
||||
Functions\expect('wp_delete_post')->never();
|
||||
|
||||
$failures = [WriteFailed::partial('Die Divi-Seite konnte nicht gespeichert werden.', postId: 7)];
|
||||
$design = DiviWriterFixture::designReader();
|
||||
$writer = new DiviWriter(
|
||||
LibraryFixtures::loader(),
|
||||
DiviWriterFixture::saver($this->saved, $failures),
|
||||
FakeChangeLog::any(),
|
||||
$design,
|
||||
DiviWriterFixture::composition($design)
|
||||
);
|
||||
|
||||
try {
|
||||
$this->ability([], true, false, [], null, $writer)->execute($this->input());
|
||||
self::fail('no WriteFailed');
|
||||
} catch (WriteFailed $e) {
|
||||
self::assertFalse($e->rolledBack);
|
||||
self::assertSame(2300, $e->details['library_id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,4 +237,26 @@ final class LibrarySaveAbilityTest extends TestCase
|
||||
self::assertToolError($this->ability()->execute($this->input(['scope' => 'site'])), 'validation_failed');
|
||||
self::assertSame([], $this->specs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling Q7: a global entry never renders another entry, in any spelling Divi renders – here a Divi 4
|
||||
* shortcode inside a text value; a normal entry may hold it (it is copied, not shown as a module).
|
||||
*/
|
||||
public function testGlobalScopeRefusesReferencesInAnySpelling(): void
|
||||
{
|
||||
$quote = chr(92) . '"';
|
||||
$this->page = LibraryFixtures::page(
|
||||
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||||
. '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"[et_pb_text global_module='
|
||||
. $quote . '1058' . $quote . ']x[/et_pb_text]"}}}} /-->'
|
||||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
|
||||
);
|
||||
LibraryFixtures::given([$this->page]);
|
||||
|
||||
$global = $this->ability()->execute($this->input(['id' => 's0', 'scope' => 'global']));
|
||||
self::assertToolError($global, 'validation_failed');
|
||||
self::assertSame([], $this->specs);
|
||||
|
||||
self::assertTrue($this->ability()->execute($this->input(['id' => 's0']))['ok']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,45 @@ final class UsageMatcherTest extends TestCase
|
||||
self::assertSame($shows, UsageMatcher::shows($content, 123));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling Q7: referencesAny() refuses every reference Divi renders, to any entry – what shows() finds for
|
||||
* some ID, and the values that cast to 0 (the newest layout – possibly the entry being created).
|
||||
*
|
||||
* @dataProvider contents
|
||||
*/
|
||||
public function testReferencesAnyFindsEveryReferenceToAnyEntry(string $content, bool $shows): void
|
||||
{
|
||||
$any = UsageMatcher::shows($content, 123) || UsageMatcher::shows($content, 124)
|
||||
|| UsageMatcher::shows($content, 1234) || UsageMatcher::shows($content, 1230);
|
||||
|
||||
self::assertSame($any, UsageMatcher::referencesAny($content));
|
||||
}
|
||||
|
||||
/** @dataProvider wildcards */
|
||||
public function testReferencesAnyFindsValuesThatCastToZero(string $content): void
|
||||
{
|
||||
self::assertTrue(UsageMatcher::referencesAny($content));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, bool}> */
|
||||
public static function anyContents(): array
|
||||
{
|
||||
return [
|
||||
'divi 5 non-numeric id' => ['<!-- wp:divi/global-layout {"globalModule":"abc","blockName":"divi/text"} /-->', true],
|
||||
'divi 5 numeric id' => ['<!-- wp:divi/global-layout {"globalModule":7} /-->', true],
|
||||
'divi 5 textual id in raw text' => ['<p>"globalModule":"9"</p>', true],
|
||||
'divi 4 other tag' => ['[et_pb_blurb global_module="55"][/et_pb_blurb]', true],
|
||||
'divi 4 zero' => ['[et_pb_section global_module="0"][/et_pb_section]', false],
|
||||
'plain text' => ['<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"<p>Global</p>"}}}} /-->', false],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider anyContents */
|
||||
public function testReferencesAnyFailsClosed(string $content, bool $references): void
|
||||
{
|
||||
self::assertSame($references, UsageMatcher::referencesAny($content));
|
||||
}
|
||||
|
||||
public function testTheEscapeBytesAreReal(): void
|
||||
{
|
||||
self::assertSame(6, strlen(self::u('0067')));
|
||||
|
||||
Reference in New Issue
Block a user