Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Compose/TextReplacerTest.php
T
janniclas.loosenandClaude Opus 5.5 d67cd0ede6 feat: lit/divi-replace-text with dry run and per-page tree_rev targets
TextReplacer replaces only in plain-text segments (S8 split), outside
script/style bodies, inserts the replacement cleaned per field kind and
refuses any result whose tags, entities or references differ from the
original plus the inserted text. Unchanged strings are never cleaned.
Per page all or nothing with the tree_rev lock; Throwables are reported
per page (S10).

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

240 lines
12 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\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\TextReplacer;
use PHPUnit\Framework\TestCase;
final class TextReplacerTest extends TestCase
{
private const ATTRS = [
'title' => ['innerContent' => ['desktop' => ['value' => 'Müller GmbH'], 'phone' => ['value' => 'Müller']]],
'content' => ['innerContent' => ['desktop' => ['value' => '<p>Seit 1987: Müller &amp; Söhne. Anruf bei MÜLLER.</p>']]],
'button' => ['innerContent' => ['desktop' => ['value' => ['text' => 'Müller anrufen', 'linkUrl' => '/mueller-kontakt']]]],
'module' => ['meta' => ['adminLabel' => ['desktop' => ['value' => 'Müller-Hero']]]],
'dyn' => ['innerContent' => ['desktop' => ['value' => '$variable({"type":"content","value":{"name":"Müller","settings":{}}})$']]],
];
private const REF = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$';
/** @return callable(string, ?string, string): string */
private static function same(): callable
{
return static fn (string $p, ?string $k, string $v): string => $v;
}
/** @param array<string, mixed> $attrs */
private static function text(string $value): array
{
return ['content' => ['innerContent' => ['desktop' => ['value' => $value]]]];
}
/** @param array<string, mixed> $attrs */
private static function valueOf(array $attrs): mixed
{
return $attrs['content']['innerContent']['desktop']['value'];
}
public function testReplacesVisibleTextOnly(): void
{
$seen = [];
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(self::ATTRS, static function (string $path, ?string $key, string $value) use (&$seen): string {
$seen[] = $path . '|' . ($key ?? '');
return $value;
});
self::assertSame(4, $result['count']);
self::assertSame('Schmidt GmbH', $result['attrs']['title']['innerContent']['desktop']['value']);
self::assertSame('Schmidt', $result['attrs']['title']['innerContent']['phone']['value']);
self::assertSame('<p>Seit 1987: Schmidt &amp; Söhne. Anruf bei MÜLLER.</p>', $result['attrs']['content']['innerContent']['desktop']['value']);
self::assertSame(['text' => 'Schmidt anrufen', 'linkUrl' => '/mueller-kontakt'], $result['attrs']['button']['innerContent']['desktop']['value']);
self::assertSame('Müller-Hero', $result['attrs']['module']['meta']['adminLabel']['desktop']['value']);
self::assertSame(self::ATTRS['dyn'], $result['attrs']['dyn']);
self::assertContains('button.innerContent|text', $seen);
self::assertSame(['path' => 'button.innerContent.text', 'before' => 'Müller anrufen', 'after' => 'Schmidt anrufen'], $result['samples'][0]);
}
public function testCaseInsensitiveIncludingUmlauts(): void
{
$result = (new TextReplacer('müller', 'Schmidt', false))->apply(self::ATTRS, self::same());
self::assertSame(5, $result['count']);
self::assertStringContainsString('Anruf bei Schmidt.', $result['attrs']['content']['innerContent']['desktop']['value']);
}
public function testNothingFound(): void
{
$result = (new TextReplacer('Meier', 'X'))->apply(self::ATTRS, self::same());
self::assertSame(0, $result['count']);
self::assertSame(self::ATTRS, $result['attrs']);
}
/** Ruling S8: tags with their attributes, entities and references are never touched. */
public function testTagsEntitiesAndReferencesStayUntouched(): void
{
$value = '<p class="kontakt-box"><a href="/kontakt" class="kontakt-link" title="Kontakt">Kontakt</a> &amp; '
. 'kontakt@kunde.test ' . self::REF . ' amp</p>';
$result = (new TextReplacer('kontakt', 'Anfrage', false))->apply(self::text($value), self::same());
self::assertSame(
'<p class="kontakt-box"><a href="/kontakt" class="kontakt-link" title="Kontakt">Anfrage</a> &amp; '
. 'Anfrage@kunde.test ' . self::REF . ' amp</p>',
self::valueOf($result['attrs'])
);
self::assertSame(2, $result['count']);
$entity = (new TextReplacer('amp', 'X'))->apply(self::text($value), self::same());
self::assertStringContainsString('&amp; ', (string) self::valueOf($entity['attrs']));
self::assertStringEndsWith(self::REF . ' X</p>', (string) self::valueOf($entity['attrs']));
self::assertSame(1, $entity['count']);
$inside = (new TextReplacer('post_title', 'post_date'))->apply(self::text('Titel: ' . self::REF), self::same());
self::assertSame(0, $inside['count'], 'text inside an embedded reference is not replaced');
}
/** Script and style bodies are code, not visible text (D10: never styles). */
public function testScriptAndStyleBodiesAreNotText(): void
{
$value = '<style>.mueller{color:red}</style><script>var name = "Müller";</script><p>Müller</p>'
. '<SCRIPT type="text/javascript">alert("Müller")</SCRIPT> Müller';
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text($value), self::same());
self::assertSame(
'<style>.mueller{color:red}</style><script>var name = "Müller";</script><p>Schmidt</p>'
. '<SCRIPT type="text/javascript">alert("Müller")</SCRIPT> Schmidt',
self::valueOf($result['attrs'])
);
self::assertSame(2, $result['count']);
$unclosed = (new TextReplacer('Müller', 'Schmidt'))->apply(self::text('<script>x("Müller")'), self::same());
self::assertSame(0, $unclosed['count'], 'an unclosed script runs to the end of the value');
}
/** The clean callback gets the replacement for each place; its result is what is inserted. */
public function testInsertsTheCleanedReplacementPerPlace(): void
{
$calls = [];
$result = (new TextReplacer('Müller', '<b>Schmidt</b>'))->apply(
self::ATTRS,
static function (string $path, ?string $key, string $value) use (&$calls): string {
$calls[] = $path . '|' . ($key ?? '');
return $path === 'title.innerContent' ? strip_tags($value) : $value;
}
);
self::assertSame('Schmidt GmbH', $result['attrs']['title']['innerContent']['desktop']['value']);
self::assertSame('<p>Seit 1987: <b>Schmidt</b> &amp; Söhne. Anruf bei MÜLLER.</p>', self::valueOf($result['attrs']));
self::assertSame(['button.innerContent|text', 'content.innerContent|', 'title.innerContent|'], $calls, 'once per place');
}
/** A place the callback refuses (null: URL or code field) stays as it is. */
public function testNullFromTheCallbackLeavesThePlace(): void
{
$result = (new TextReplacer('Müller', 'Schmidt'))->apply(
self::ATTRS,
static fn (string $p, ?string $k, string $v): ?string => $p === 'content.innerContent' ? null : $v
);
self::assertSame(3, $result['count']);
self::assertSame(self::ATTRS['content'], $result['attrs']['content']);
}
/** Forward risk (Task 1): unchanged strings are never handed to the callback and stay byte for byte. */
public function testUnchangedStringsAreNeverCleaned(): void
{
$odd = '$variable({"type":"content","value":{"name":"not_in_the_allow_list","settings":{"x":"<b>"}}})$';
$attrs = [
'title' => ['innerContent' => ['desktop' => ['value' => 'Müller']]],
'content' => ['innerContent' => ['desktop' => ['value' => $odd], 'tablet' => ['value' => " a\t<br>b %41 "]]],
];
$seen = [];
$result = (new TextReplacer('Müller', 'Schmidt'))->apply($attrs, static function (string $p, ?string $k, string $v) use (&$seen): string {
$seen[] = $p;
return $v;
});
self::assertSame(['title.innerContent'], $seen);
self::assertSame($attrs['content'], $result['attrs']['content']);
}
/**
* Ruling S8 hardening: a result whose tags, entities or references differ from the original
* ones plus the inserted replacement's own is refused – nothing is changed.
*
* @return array<string, array{string, string, string}>
*/
public static function crossBoundary(): array
{
return [
'replacement closes a lone "<" into a tag' => ['a < b Müller c', 'Müller', 'x onmouseover=alert(1)>'],
'replacement opens a tag closed by a lone ">"' => ['Müller c>', 'Müller', '<img src=x onerror=alert(1) '],
'replacement completes an entity' => ['Müller;', 'Müller', '&amp'],
'removal glues an entity together' => ['&amp--; text', '--', ''],
'replacement closes a partial reference' => ['$variable({"type":"content","value":{"name":"post_title","settings":{}}} END', 'END', ')$'],
'nested reference' => ['$variable({"type":"content","value":{"name":"post_title","settings":{"before":"$variable(x)$ Müller"}}})$', 'Müller', 'x'],
'reference across a line break' => ["\$variable({\"type\":\"content\",\n\"value\":{\"name\":\"post_title\"}})$ Müller", 'Müller', 'x'],
'removal glues a marker' => ['$varia--ble( Müller', '--', ''],
];
}
/** @dataProvider crossBoundary */
public function testRefusesResultsThatChangeStructure(string $value, string $search, string $replace): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('content.innerContent');
(new TextReplacer($search, $replace))->apply(self::text($value), self::same());
}
public function testRefusesAMarkerFromTheCallback(): void
{
$this->expectException(InvalidArgumentException::class);
(new TextReplacer('Müller', 'x'))->apply(self::text('Müller'), static fn (): string => self::REF);
}
/** @return array<string, array{string, string, string}> */
public static function badInput(): array
{
return [
'short search' => ['M', 'x', 'search'],
'empty search' => ['', 'x', 'search'],
'search with marker' => ['$variable(', 'x', 'search'],
'invalid UTF-8 search' => ["M\xC3", 'x', 'search'],
'invalid UTF-8 replacement' => ['Müller', "x\xC3", 'replace'],
'replacement is a reference' => ['Müller', self::REF, 'replace'],
'replacement embeds a reference' => ['Müller', 'Hallo ' . self::REF . ' Welt', 'replace'],
'tag-split marker (S28)' => ['Müller', '$variable<x>({"type":"content","value":{"name":"post_title"}})$', 'replace'],
'nested tag-split marker' => ['Müller', '$vari<<x>x>able(', 'replace'],
'NUL-split marker (S32a)' => ['Müller', "\$vari\0able(", 'replace'],
'VT-split marker' => ['Müller', "\$variable\x0B(", 'replace'],
'octet-split marker' => ['Müller', '$v%41ariable(', 'replace'],
'nested octet-split marker' => ['Müller', '$v%4%411ariable(', 'replace'],
];
}
/** @dataProvider badInput */
public function testProblemsNameTheField(string $search, string $replace, string $field): void
{
self::assertArrayHasKey($field, TextReplacer::problems($search, $replace));
$this->expectException(InvalidArgumentException::class);
new TextReplacer($search, $replace);
}
public function testFineInputHasNoProblems(): void
{
self::assertSame([], TextReplacer::problems('Müller', ''));
self::assertSame([], TextReplacer::problems('$5', 'Preis (netto) $'));
}
}