Files
janniclas.loosenandClaude Opus 5.5 c7ce924e5a fix: library IDs stay hidden without library access (ruling P13)
Outline, node-get, write outlines, the render summary and replace-text's
opaque-node match name no library ID unless the library group is visible
and the user has divi_library; read-only still names them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:00:39 +02:00

211 lines
9.8 KiB
PHP

<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Render;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\WordPress\Render\PageRenderer;
use LIT\MCP\Integrations\WordPress\Render\PageRenderSummaryAbility;
use LIT\MCP\Integrations\WordPress\Render\RenderSummary;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\SchemaFixture;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;
final class PageRenderSummaryAbilityTest extends TestCase
{
private const HTML = '<div class="et_pb_section"><h1>Willkommen</h1><h3>Leistungen</h3>'
. '<p>Seit 1987 <a href="/kontakt">in Köln</a>.</p><ul><li>Beratung</li></ul>'
. '<img src="https://k.test/a.jpg" alt="Team"><img src="https://k.test/b.jpg"><a href="#">Mehr</a></div>';
public function testSummarizesHtml(): void
{
$summary = RenderSummary::fromHtml(self::HTML);
self::assertSame([['level' => 1, 'text' => 'Willkommen'], ['level' => 3, 'text' => 'Leistungen']], $summary['headings']);
self::assertSame(['Seit 1987 in Köln.', 'Beratung'], $summary['texts']);
self::assertSame(['text' => 'in Köln', 'href' => '/kontakt'], $summary['links'][0]);
self::assertSame(
[['src' => 'https://k.test/a.jpg', 'alt' => 'Team'], ['src' => 'https://k.test/b.jpg', 'alt' => null]],
$summary['images']
);
self::assertSame(
[
'Sprung in der Überschriftenhierarchie: h1 → h3 ("Leistungen").',
'Bild ohne Alt-Text: https://k.test/b.jpg',
'Link ohne Ziel: "Mehr".',
],
$summary['warnings']
);
self::assertSame([], RenderSummary::fromHtml('')['headings']);
self::assertCount(2, RenderSummary::fromHtml(str_repeat('<p>x</p>', 5), 2)['texts']);
}
public function testTruncatesLongExtractedStrings(): void
{
$longHeading = str_repeat('Ü', 250);
$longText = str_repeat('a', 400);
$longLinkText = str_repeat('b', 200);
$longHref = 'https://k.test/' . str_repeat('c', 400);
$longAlt = str_repeat('d', 300);
$html = "<h2>{$longHeading}</h2><p>{$longText}</p>"
. "<a href=\"{$longHref}\">{$longLinkText}</a>"
. "<img src=\"https://k.test/x.jpg\" alt=\"{$longAlt}\">";
$summary = RenderSummary::fromHtml($html);
self::assertSame(200, mb_strlen($summary['headings'][0]['text']));
self::assertStringEndsWith('…', $summary['headings'][0]['text']);
self::assertSame(300, mb_strlen($summary['texts'][0]));
self::assertStringEndsWith('…', $summary['texts'][0]);
self::assertSame(150, mb_strlen($summary['links'][0]['text']));
self::assertStringEndsWith('…', $summary['links'][0]['text']);
self::assertSame(300, mb_strlen($summary['links'][0]['href']));
self::assertStringEndsWith('…', $summary['links'][0]['href']);
self::assertSame(200, mb_strlen($summary['images'][0]['alt']));
self::assertStringEndsWith('…', $summary['images'][0]['alt']);
}
public function testRepresentsDataUrisAsAShortMarkerInsteadOfTruncatingThem(): void
{
$dataUri = 'data:image/png;base64,' . base64_encode(str_repeat('x', 5000));
$html = '<img src="' . $dataUri . '"><a href="' . $dataUri . '">Anhang</a>';
$summary = RenderSummary::fromHtml($html);
$marker = 'data:image/png;… (' . strlen($dataUri) . ' Bytes)';
self::assertSame($marker, $summary['images'][0]['src']);
self::assertSame($marker, $summary['links'][0]['href']);
}
public function testPrefersDataSrcOrDataLazySrcOverAPlaceholderSrc(): void
{
$html = '<img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" data-src="https://k.test/real.jpg" alt="Team">'
. '<img src="placeholder.gif" data-lazy-src="https://k.test/lazy.jpg">';
$summary = RenderSummary::fromHtml($html);
self::assertSame('https://k.test/real.jpg', $summary['images'][0]['src']);
self::assertSame('https://k.test/lazy.jpg', $summary['images'][1]['src']);
}
public function testWarnsAboutMultipleH1Headings(): void
{
$summary = RenderSummary::fromHtml('<h1>Erste</h1><h1>Zweite</h1>');
self::assertContains('2 Überschriften der Ebene h1 im Inhalt.', $summary['warnings']);
}
public function testDoesNotDuplicateTextFromNestedParagraphsListItemsOrBlockquotes(): void
{
$html = '<ul><li>Punkt <p>eingebettet</p></li></ul>'
. '<blockquote>Zitat <blockquote>innen</blockquote></blockquote>';
$summary = RenderSummary::fromHtml($html);
self::assertSame(['Punkt eingebettet', 'Zitat innen'], $summary['texts']);
}
public function testIgnoresScriptStyleAndNoscriptText(): void
{
$html = '<p>Sichtbar<script>var x = 1;</script><style>.a{color:red}</style>'
. '<noscript>Fallback</noscript> Ende</p>';
$summary = RenderSummary::fromHtml($html);
self::assertSame(['Sichtbar Ende'], $summary['texts']);
}
public function testRendersTheEntryAndAddsNotes(): void
{
$post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_password' => 'geheim']);
Functions\when('get_post')->justReturn($post);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_permalink')->justReturn('https://k.test/start/');
$schema = SchemaFixture::managed();
$renderer = Mockery::mock(PageRenderer::class);
$renderer->expects('render')->with($post)->andReturn(self::HTML);
$ability = new PageRenderSummaryAbility($schema, $renderer);
self::assertTrue($ability->permission(['post_id' => 7]));
$result = $ability->execute(['post_id' => 7]);
self::assertSame(7, $result['post_id']);
self::assertSame('https://k.test/start/', $result['url']);
self::assertCount(2, $result['headings']);
self::assertCount(3, $result['notes']);
self::assertTrue($ability->isDirect());
self::assertTrue($ability->annotations()['readonly']);
}
public function testNotesEveryGlobalModuleTheSummaryCannotShow(): void
{
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
$post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]);
Functions\when('get_post')->justReturn($post);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7');
$renderer = Mockery::mock(PageRenderer::class);
$renderer->allows('render')->andReturn('<p>Text</p>');
$ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer);
$notes = $ability->execute(['post_id' => 7])['notes'];
$globals = array_values(array_filter($notes, static fn (string $note): bool => str_starts_with($note, 'Globales Modul')));
self::assertCount(2, $globals);
self::assertStringContainsString('Globales Modul 1058 ist im Auszug nicht enthalten', $globals[0]);
self::assertStringContainsString('lit-divi-outline mit post_id 1059', $globals[1]);
}
/** Ruling P13: without library access the note counts global modules instead of naming them. */
public function testWithoutLibraryAccessGlobalModulesAreOnlyCounted(): void
{
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
$post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]);
Functions\when('get_post')->justReturn($post);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7');
$renderer = Mockery::mock(PageRenderer::class);
$renderer->allows('render')->andReturn('<p>Text</p>');
$ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer, static fn (): bool => false);
$result = $ability->execute(['post_id' => 7]);
$joined = implode("\n", $result['notes']);
self::assertStringContainsString('2 globale Module sind im Auszug nicht enthalten', $joined);
self::assertStringNotContainsString('1058', $joined);
self::assertStringNotContainsString('1059', $joined);
self::assertStringNotContainsString('1058', (string) json_encode($result));
}
/** Ruling P13: with library access the notes name each entry. */
public function testWithLibraryAccessGlobalModulesAreNamed(): void
{
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
$post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]);
Functions\when('get_post')->justReturn($post);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7');
$renderer = Mockery::mock(PageRenderer::class);
$renderer->allows('render')->andReturn('<p>Text</p>');
$ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer, static fn (): bool => true);
$joined = implode("\n", $ability->execute(['post_id' => 7])['notes']);
self::assertStringContainsString('Globales Modul 1058', $joined);
}
public function testLibraryEntriesAreNoManagedContent(): void
{
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout']));
$schema = Mockery::mock(ContentSchema::class);
$schema->allows('isManaged')->with('et_pb_layout')->andReturn(false);
$ability = new PageRenderSummaryAbility($schema, Mockery::mock(PageRenderer::class));
self::assertToolError($ability->permission(['post_id' => 1058]), 'not_found');
}
}