feat: structure and content fingerprints, lit/library-find-similar
Fingerprint::structure()/content() (E11) and lit/library-find-similar (indirect, group Divi-Bibliothek): at most 200 posts per call (cursor), editable entries only (own posts without edit_others_posts), contents primed in chunks of 50, stops at 200 matches or 5000 compared nodes with a note; library entries and Theme Builder layouts are never scanned. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class FingerprintTest extends TestCase
|
||||
{
|
||||
/** The section of library entry 1055: section > row > column (24_24) > blurb. */
|
||||
private static function section(): Block
|
||||
{
|
||||
$content = DiviFixtures::markup('stage3/library--library-abschnitt.html');
|
||||
|
||||
return LibraryContent::roots(LibraryContent::load(new Parser(), $content))[0];
|
||||
}
|
||||
|
||||
private static function blurbOf(Block $section): Block
|
||||
{
|
||||
return $section->children()[0]->children()[0]->children()[0];
|
||||
}
|
||||
|
||||
public function testAdminLabelsAndBuilderVersionDoNotCount(): void
|
||||
{
|
||||
$a = self::section();
|
||||
$b = $a->copy();
|
||||
$attrs = $b->attrs();
|
||||
$attrs['module']['meta']['adminLabel']['desktop']['value'] = 'Anderes Label';
|
||||
$attrs['builderVersion'] = '5.3.0';
|
||||
$b->setAttrs($attrs);
|
||||
|
||||
self::assertSame(16, strlen(Fingerprint::content($a)));
|
||||
self::assertSame(Fingerprint::content($a), Fingerprint::content($b));
|
||||
self::assertSame(Fingerprint::structure($a), Fingerprint::structure($b));
|
||||
}
|
||||
|
||||
public function testOtherTextsKeepTheStructureButNotTheContent(): void
|
||||
{
|
||||
$a = self::section();
|
||||
$b = $a->copy();
|
||||
$blurb = self::blurbOf($b);
|
||||
$attrs = $blurb->attrs();
|
||||
$attrs['title']['innerContent']['desktop']['value'] = 'Anderer Titel';
|
||||
$blurb->setAttrs($attrs);
|
||||
|
||||
self::assertSame(Fingerprint::structure($a), Fingerprint::structure($b));
|
||||
self::assertNotSame(Fingerprint::content($a), Fingerprint::content($b));
|
||||
}
|
||||
|
||||
public function testColumnLayoutAndModuleTypesChangeTheStructure(): void
|
||||
{
|
||||
$a = self::section();
|
||||
$b = $a->copy();
|
||||
$column = $b->children()[0]->children()[0];
|
||||
$attrs = $column->attrs();
|
||||
$attrs['module']['decoration']['sizing']['desktop']['value']['flexType'] = '12_24';
|
||||
$column->setAttrs($attrs);
|
||||
$c = Block::create('divi/section', [], [Block::create('divi/row', [], [Block::create('divi/column', [], [
|
||||
Block::create('divi/text'),
|
||||
])])]);
|
||||
|
||||
self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($b));
|
||||
self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($c));
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ 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-list', 'lit/library-save', 'lit/library-insert', 'lit/library-find-similar'],
|
||||
array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities())
|
||||
);
|
||||
foreach ($library->abilities() as $ability) {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Abilities\Cursor;
|
||||
use LIT\MCP\Abilities\Rev;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||
use LIT\MCP\Integrations\Divi\Library\LibraryFindSimilarAbility;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use WP_Post;
|
||||
|
||||
final class LibraryFindSimilarAbilityTest extends TestCase
|
||||
{
|
||||
private string $variation = '';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$root = LibraryContent::roots(LibraryContent::load(
|
||||
new Parser(),
|
||||
DiviFixtures::markup('stage3/library--library-abschnitt.html')
|
||||
))[0];
|
||||
$copy = Serializer::block($root);
|
||||
$this->variation = str_replace('Library-Blurb', 'Anderer Blurb', $copy);
|
||||
$posts = [
|
||||
LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html')),
|
||||
new WP_Post([
|
||||
'ID' => 8,
|
||||
'post_type' => 'page',
|
||||
'post_status' => 'publish',
|
||||
'post_title' => 'Über uns',
|
||||
'post_content' => '<!-- wp:divi/placeholder -->' . $this->variation . $copy . '<!-- /wp:divi/placeholder -->',
|
||||
]),
|
||||
new WP_Post(['ID' => 9, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Fremd', 'post_content' => $copy]),
|
||||
];
|
||||
$items = array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS));
|
||||
LibraryFixtures::given(array_merge($posts, $items));
|
||||
Functions\when('current_user_can')->alias(
|
||||
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 9)
|
||||
);
|
||||
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [
|
||||
'edit_posts' => 'edit_posts',
|
||||
'edit_others_posts' => 'edit_others_posts',
|
||||
]]);
|
||||
Functions\when('_get_non_cached_ids')->returnArg(1);
|
||||
Functions\when('_prime_post_caches')->alias(function (array $ids): void {
|
||||
$this->primed[] = $ids;
|
||||
});
|
||||
Functions\when('wp_cache_delete')->justReturn(true);
|
||||
}
|
||||
|
||||
/** @var list<list<int>> */
|
||||
private array $primed = [];
|
||||
|
||||
private static function ability(array $deniedRoles = []): LibraryFindSimilarAbility
|
||||
{
|
||||
$access = LibraryFixtures::access($deniedRoles);
|
||||
|
||||
return new LibraryFindSimilarAbility($access, LibraryFixtures::loader($access), DiviFixtures::schema());
|
||||
}
|
||||
|
||||
public function testFindsIdenticalAndStructurallySimilarNodesOfEditableEntries(): void
|
||||
{
|
||||
$query = [];
|
||||
Functions\when('get_posts')->alias(static function (array $args) use (&$query): array {
|
||||
$query = $args;
|
||||
|
||||
return [7, 8, 9];
|
||||
});
|
||||
|
||||
$result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']);
|
||||
|
||||
self::assertSame(1055, $result['reference']['library_id']);
|
||||
self::assertSame('divi/section', $result['reference']['type']);
|
||||
$found = array_map(static fn (array $m): string => $m['post_id'] . ' ' . $m['id'] . ' ' . $m['match'], $result['matches']);
|
||||
self::assertSame(['7 s1 identical', '8 s0 structure', '8 s1 identical'], $found, 'post 9 is not editable');
|
||||
self::assertSame(Rev::of((string) get_post(8)->post_content), $result['matches'][1]['tree_rev']);
|
||||
self::assertSame(3, $result['scanned']);
|
||||
self::assertNull($result['next_cursor']);
|
||||
self::assertSame(['page'], $query['post_type']);
|
||||
self::assertSame(101, $query['posts_per_page']);
|
||||
self::assertSame('ids', $query['fields']);
|
||||
}
|
||||
|
||||
public function testANodeOfAPageIsTheReferenceAndIsNotItsOwnMatch(): void
|
||||
{
|
||||
Functions\when('get_posts')->justReturn([7, 8]);
|
||||
|
||||
$result = self::ability()->execute(['post_id' => 7, 'id' => 's1', 'post_type' => 'page', 'limit' => 1]);
|
||||
|
||||
self::assertSame(['s1'], [$result['reference']['id']]);
|
||||
self::assertSame([], array_filter($result['matches'], static fn (array $m): bool => $m['post_id'] === 7));
|
||||
self::assertSame(1, $result['scanned']);
|
||||
self::assertSame(Cursor::encode(1), $result['next_cursor']);
|
||||
}
|
||||
|
||||
public function testRefusals(): void
|
||||
{
|
||||
Functions\when('get_posts')->justReturn([]);
|
||||
|
||||
self::assertToolError(self::ability(['divi_library'])->execute(['library_id' => 1055]), 'forbidden');
|
||||
self::assertToolError(self::ability()->execute([]), 'validation_failed');
|
||||
self::assertToolError(self::ability()->execute(['library_id' => 1054]), 'validation_failed', 'a layout entry has several roots');
|
||||
self::assertToolError(self::ability()->execute(['post_id' => 7, 'id' => 'g0']), 'validation_failed');
|
||||
self::assertToolError(self::ability()->execute(['library_id' => 1055, 'cursor' => '%%%']), 'validation_failed');
|
||||
self::assertTrue(self::ability()->annotations()['readonly']);
|
||||
}
|
||||
|
||||
/** No N+1: the contents come in chunks of CONTENT_CHUNK through one primed post cache each. */
|
||||
public function testLoadsTheContentsInChunks(): void
|
||||
{
|
||||
$ids = range(1000, 1000 + LibraryFindSimilarAbility::CONTENT_CHUNK + 4);
|
||||
Functions\when('get_posts')->justReturn($ids);
|
||||
|
||||
$result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page', 'limit' => 200]);
|
||||
|
||||
self::assertSame(count($ids), $result['scanned']);
|
||||
self::assertSame([array_slice($ids, 0, LibraryFindSimilarAbility::CONTENT_CHUNK), array_slice(
|
||||
$ids,
|
||||
LibraryFindSimilarAbility::CONTENT_CHUNK
|
||||
)], $this->primed);
|
||||
}
|
||||
|
||||
/** Without edit_others_posts the query itself keeps to the user's own posts (no cursor oracle). */
|
||||
public function testWithoutEditOthersPostsOnlyOwnPostsAreQueried(): void
|
||||
{
|
||||
$query = [];
|
||||
Functions\when('get_posts')->alias(static function (array $args) use (&$query): array {
|
||||
$query = $args;
|
||||
|
||||
return [];
|
||||
});
|
||||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts');
|
||||
Functions\when('get_current_user_id')->justReturn(42);
|
||||
|
||||
self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']);
|
||||
|
||||
self::assertSame(42, $query['author']);
|
||||
}
|
||||
|
||||
/** Library entries and Theme Builder layouts are never scanned (they have their own gates). */
|
||||
public function testLibraryAndThemeBuilderTypesAreNotSearchable(): void
|
||||
{
|
||||
Functions\expect('get_posts')->never();
|
||||
|
||||
foreach (['et_pb_layout', 'et_body_layout', 'et_header_layout', 'et_footer_layout', 'et_template'] as $type) {
|
||||
self::assertToolError(
|
||||
self::ability()->execute(['library_id' => 1055, 'post_type' => $type]),
|
||||
'not_found'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** With the library group hidden the library entry is answered like a missing ID (P9/P12). */
|
||||
public function testAHiddenLibraryAnswersLikeAMissingEntry(): void
|
||||
{
|
||||
Functions\when('get_posts')->justReturn([]);
|
||||
$access = new \LIT\MCP\Integrations\Divi\Library\LibraryAccess(
|
||||
LibraryFixtures::store(),
|
||||
LibraryFixtures::roles(),
|
||||
static fn (): bool => true,
|
||||
static fn (): bool => false
|
||||
);
|
||||
$ability = new LibraryFindSimilarAbility($access, LibraryFixtures::loader($access), DiviFixtures::schema());
|
||||
|
||||
$hidden = $ability->execute(['library_id' => 1055, 'post_type' => 'page']);
|
||||
$missing = $ability->execute(['library_id' => 4711, 'post_type' => 'page']);
|
||||
|
||||
self::assertToolError($hidden, 'not_found');
|
||||
self::assertSame(
|
||||
str_replace('1055', 'X', $hidden->get_error_message()),
|
||||
str_replace('4711', 'X', $missing->get_error_message())
|
||||
);
|
||||
}
|
||||
|
||||
/** At most MAX_MATCHES matches: the scan stops after that post, says so and resumes behind it. */
|
||||
public function testStopsAtMaxMatchesWithANoteAndACursor(): void
|
||||
{
|
||||
$section = Serializer::block(LibraryContent::roots(LibraryContent::load(
|
||||
new Parser(),
|
||||
DiviFixtures::markup('stage3/library--library-abschnitt.html')
|
||||
))[0]);
|
||||
$many = static fn (int $id, int $count): WP_Post => new WP_Post([
|
||||
'ID' => $id,
|
||||
'post_type' => 'page',
|
||||
'post_status' => 'publish',
|
||||
'post_title' => 'Viele ' . $id,
|
||||
'post_content' => '<!-- wp:divi/placeholder -->' . str_repeat($section, $count)
|
||||
. '<!-- /wp:divi/placeholder -->',
|
||||
]);
|
||||
$items = array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS));
|
||||
LibraryFixtures::given(array_merge([$many(20, 150), $many(21, 120), $many(22, 5)], $items));
|
||||
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) []]);
|
||||
Functions\when('get_posts')->justReturn([20, 21, 22]);
|
||||
|
||||
$result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']);
|
||||
|
||||
self::assertCount(LibraryFindSimilarAbility::MAX_MATCHES, $result['matches']);
|
||||
self::assertSame(2, $result['scanned']);
|
||||
self::assertSame(Cursor::encode(2), $result['next_cursor']);
|
||||
self::assertStringContainsString('200', $result['note']);
|
||||
|
||||
Functions\when('get_posts')->justReturn([22]);
|
||||
$rest = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page', 'cursor' => Cursor::encode(2)]);
|
||||
self::assertCount(5, $rest['matches']);
|
||||
self::assertArrayNotHasKey('note', $rest);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user