Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php
T
janniclas.loosenandClaude Opus 5.5 e7bbe1e9d7 fix: find-similar marks replaceable matches and names excluded types (review M-3, M-7, M-8)
Every match carries replaceable: true only when it is identical and in a
page or post, the scope lit/library-make-global replaces; the description
and the guide say so. An excluded post type is answered like any
unavailable one, by its name (PostGuard::unavailableType()), and the
excluded list takes the Theme Builder types from DiviThemeBuilder::postTypes().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-27 23:09:13 +02:00

281 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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', 'et_theme_builder']
as $type
) {
$refused = self::ability()->execute(['library_id' => 1055, 'post_type' => $type]);
self::assertToolError($refused, 'not_found');
// Review M-7: named like any unavailable type, never as "".
self::assertSame(sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $type), self::clientError($refused)['message']);
}
}
/**
* Review M-8: lit/library-make-global replaces only pages and posts – every match says whether it can be
* replaced there (identical and of such a type).
*/
public function testMatchesSayWhetherMakeGlobalCanReplaceThem(): void
{
$project = get_post(8);
$project = new WP_Post(['ID' => 10, 'post_type' => 'project', 'post_status' => 'publish', 'post_title' => 'Projekt',
'post_content' => $project->post_content]);
LibraryFixtures::given(array_merge([get_post(8), $project], array_map(
static fn (int $id): WP_Post => LibraryFixtures::item($id),
array_keys(LibraryFixtures::ITEMS)
)));
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [
'edit_posts' => 'edit_posts',
'edit_others_posts' => 'edit_others_posts',
]]);
// The query is mocked: it stands for a scan over several Divi-enabled types.
Functions\when('get_posts')->justReturn([8, 10]);
$result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']);
$found = array_map(
static fn (array $m): string => sprintf('%d %s %s %s', $m['post_id'], $m['id'], $m['match'], json_encode($m['replaceable'])),
$result['matches']
);
self::assertSame(
['8 s0 structure false', '8 s1 identical true', '10 s0 structure false', '10 s1 identical false'],
$found
);
self::assertStringContainsString('replaceable', self::ability()->description());
}
/** 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);
}
/** Ruling Q25: raw HTML next to the blocks is visible – such a copy is only "structure", never "identical". */
public function testExtraVisibleTextIsNotIdentical(): void
{
$root = LibraryContent::roots(LibraryContent::load(
new Parser(),
DiviFixtures::markup('stage3/library--library-abschnitt.html')
))[0];
$copy = Serializer::block($root);
$at = strrpos($copy, '<!-- /wp:divi/column -->');
$changed = substr($copy, 0, (int) $at) . '<p>ONLY ON 8</p>' . substr($copy, (int) $at);
LibraryFixtures::given([
LibraryFixtures::item(1055),
new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Über uns',
'post_content' => '<!-- wp:divi/placeholder -->' . $changed . '<!-- /wp:divi/placeholder -->']),
]);
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [
'edit_posts' => 'edit_posts',
'edit_others_posts' => 'edit_others_posts',
]]);
Functions\when('get_posts')->justReturn([8]);
$result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']);
self::assertSame(['8 s0 structure'], array_map(
static fn (array $m): string => $m['post_id'] . ' ' . $m['id'] . ' ' . $m['match'],
$result['matches']
));
}
}