- 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
163 lines
8.2 KiB
PHP
163 lines
8.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Integrations\Divi\Library\UsageMatcher;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
/**
|
||
* Ruling P23 (re-review F1): every reference Divi renders counts – Divi 5 openers in any JSON
|
||
* spelling, Divi 4 shortcodes in any quoting; an escaped opener that cannot be decoded counts as a
|
||
* possible reference (fail closed). Escape bytes are built with chr(92), never written inline.
|
||
*/
|
||
final class UsageMatcherTest extends TestCase
|
||
{
|
||
private static function u(string $hex): string
|
||
{
|
||
return chr(92) . 'u' . $hex;
|
||
}
|
||
|
||
/** @return array<string, array{string, bool}> Content => whether it shows entry 123. */
|
||
public static function contents(): array
|
||
{
|
||
$escapedKey = '"' . self::u('0067') . 'lobalModule"';
|
||
|
||
return [
|
||
'divi 5 canonical' => ['<!-- wp:divi/global-layout {"globalModule":"123","blockName":"divi/text"} /-->', true],
|
||
'divi 5 leading zeros' => ['<!-- wp:divi/global-layout {"globalModule":"0123","blockName":"divi/text"} /-->', true],
|
||
'divi 5 escaped key' => ['<!-- wp:divi/global-layout {' . $escapedKey . ':"123","blockName":"divi/text"} /-->', true],
|
||
'divi 5 escaped value' => [
|
||
'<!-- wp:divi/global-layout {"globalModule":"' . self::u('0031') . '23","blockName":"divi/text"} /-->',
|
||
true,
|
||
],
|
||
'divi 5 escaped key with leading zeros' => [
|
||
'<!-- wp:divi/global-layout {' . $escapedKey . ':"00123"} /-->',
|
||
true,
|
||
],
|
||
'divi 5 another entry' => ['<!-- wp:divi/global-layout {"globalModule":"1234","blockName":"divi/text"} /-->', false],
|
||
'divi 5 escaped key, another entry' => ['<!-- wp:divi/global-layout {' . $escapedKey . ':"124"} /-->', false],
|
||
'divi 5 id in local attributes' => [
|
||
'<!-- wp:divi/global-layout {"globalModule":"5","localAttrs":{"x":{"globalModule":"123"}}} /-->',
|
||
true,
|
||
],
|
||
'divi 4 double quotes' => ['[et_pb_section global_module="123"][/et_pb_section]', true],
|
||
'divi 4 single quotes and zeros' => ["[et_pb_text global_module='0123']x[/et_pb_text]", true],
|
||
'divi 4 unquoted' => ['[et_pb_row global_module=123 admin_label="x"][/et_pb_row]', true],
|
||
'divi 4 typographic quotes' => ['[et_pb_row global_module=' . "\u{201D}" . '123' . "\u{201D}" . '][/et_pb_row]', true],
|
||
'divi 4 another entry' => ['[et_pb_section global_module="1230"][/et_pb_section]', false],
|
||
'undecodable escaped opener' => [
|
||
'<!-- wp:divi/global-layout {' . $escapedKey . ':"123",} /-->',
|
||
true,
|
||
],
|
||
'undecodable escaped opener naming nothing readable' => [
|
||
'<!-- wp:divi/text {' . self::u('0067') . 'kaputt} /-->',
|
||
true,
|
||
],
|
||
'escaped markup only' => [
|
||
'<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"' . self::u('003c') . 'p'
|
||
. self::u('003e') . 'x"}}}} /-->',
|
||
false,
|
||
],
|
||
'plain page' => ['<!-- wp:divi/section --><!-- /wp:divi/section -->', false],
|
||
// Ruling P24 G1: a Divi 4 shortcode inside a Divi 5 attribute value (quotes serialized as escapes).
|
||
'shortcode in a divi 5 text value' => [self::inText('[et_pb_section global_module=' . self::u('0022') . '123'
|
||
. self::u('0022') . '][/et_pb_section]'), true],
|
||
'shortcode in a divi 5 text value, another entry' => [self::inText('[et_pb_section global_module='
|
||
. self::u('0022') . '124' . self::u('0022') . '][/et_pb_section]'), false],
|
||
'shortcode in a divi 5 text value, escaped digits' => [self::inText('[et_pb_section global_module='
|
||
. self::u('0022') . self::u('0031') . self::u('0032') . '3' . self::u('0022') . '][/et_pb_section]'), true],
|
||
// G2: parsed like shortcode_parse_atts, cast with (int) like Divi.
|
||
'divi 4 leading space' => ['[et_pb_section global_module=" 123"][/et_pb_section]', true],
|
||
'divi 4 exponent' => ['[et_pb_section global_module="1.23e2"][/et_pb_section]', true],
|
||
'divi 4 upper-case attribute' => ['[et_pb_section GLOBAL_MODULE="123"][/et_pb_section]', true],
|
||
'divi 4 zero renders nothing' => ['[et_pb_section global_module="0"][/et_pb_section]', false],
|
||
'divi 4 empty renders nothing' => ['[et_pb_section global_module=""][/et_pb_section]', false],
|
||
'global_module as plain text' => ['<p>global_module="123" steht hier nur als Text</p>', false],
|
||
];
|
||
}
|
||
|
||
/** A Divi 5 text module whose content value holds $text, JSON-encoded like WordPress does. */
|
||
private static function inText(string $text): string
|
||
{
|
||
return '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"' . $text . '"}}}} /-->';
|
||
}
|
||
|
||
/**
|
||
* Ruling P24 G3: a global_module Divi casts to 0 renders the newest library or TB layout
|
||
* (et_pb_load_global_module, WP_Query p=0) – a reference to every entry.
|
||
*
|
||
* @return array<string, array{string}>
|
||
*/
|
||
public static function wildcards(): array
|
||
{
|
||
return [
|
||
'entity before the id' => ['[et_pb_section global_module=""123"][/et_pb_section]'],
|
||
'letters' => ['[et_pb_row global_module="abc"][/et_pb_row]'],
|
||
'typographic quotes' => ['[et_pb_row global_module=' . "\u{201D}" . '123' . "\u{201D}" . '][/et_pb_row]'],
|
||
'double zero' => ['[et_pb_row global_module="00"][/et_pb_row]'],
|
||
'inside a divi 5 value' => [self::inText('[et_pb_text global_module=' . self::u('0022') . 'x'
|
||
. self::u('0022') . ']a[/et_pb_text]')],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider wildcards */
|
||
public function testAValueThatCastsToZeroShowsEveryEntry(string $content): void
|
||
{
|
||
self::assertTrue(UsageMatcher::shows($content, 123));
|
||
self::assertTrue(UsageMatcher::shows($content, 98765));
|
||
}
|
||
|
||
/** @dataProvider contents */
|
||
public function testFindsEveryReferenceDiviRenders(string $content, bool $shows): void
|
||
{
|
||
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')));
|
||
self::assertSame(92, ord(self::u('0067')[0]));
|
||
}
|
||
}
|