test: shared Divi fixtures in tests/Support (T1)

- DiviFixtures: markup(), givenPost(), schema(), designSystem(), catalog(),
  dynamicContentRegistry(), maliciousRefs() - moved verbatim out of
  DiviPageLoaderTest, DesignSystemTest, ModuleCatalogTest, DynamicContentMapTest
  and VariableRefTest (its malicious() provider now returns maliciousRefs()).
- ComposeFixture moves to tests/Support.
- DiviWriterFixture: modules(), designReader(), composition(), a recording saver(),
  log() and writer(&$saved, &$failures, unfiltered, loader) for the five tests that
  wired the Divi write stack by hand.
No test cases or assertions changed (1397 / 5834).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 17:47:11 +02:00
co-authored by Claude Opus 5.5
parent d2cc8497c9
commit 3d96a41366
24 changed files with 489 additions and 404 deletions
+172
View File
@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Support;
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use LIT\MCP\Integrations\Divi\Compose\Composer;
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
/**
* Compose classes wired with the real Divi 5.2.1 sample catalog, the sample design system and
* predictable fakes for sanitizing and media (no WordPress). $unfiltered switches the fake
* sanitizer between a user with and without unfiltered_html (ruling S7).
*/
final class ComposeFixture
{
/** A valid content reference as an attachment's alt text (media ids 44 and 45). */
public const REFERENCE_ALT = '$variable({"type":"content","value":{"name":"post_title","settings":{}}})$';
public static function sanitizer(bool $unfiltered = false): ValueSanitizer
{
return new class ($unfiltered) implements ValueSanitizer {
public function __construct(private bool $unfiltered)
{
}
/**
* Like sanitize_text_field (_sanitize_text_fields): tags only go when there is a "<"
* (wp_strip_all_tags; PHP's strip_tags also drops NUL then), whitespace runs become one
* space, trim, %XX octets go repeatedly. Without a "<", NUL and other control
* characters stay – as in WordPress (ruling S32: AttrWriter strips them).
*/
public function text(string $value): string
{
if (str_contains($value, '<')) {
$value = strip_tags((string) preg_replace('@<(script|style)[^>]*?>.*?</\\1>@si', '', $value));
}
$value = trim((string) preg_replace('/[\r\n\t ]+/', ' ', $value));
$found = false;
while (preg_match('/%[a-f0-9]{2}/i', $value, $match) === 1) {
$value = str_replace($match[0], '', $value);
$found = true;
}
return $found ? trim((string) preg_replace('/ +/', ' ', $value)) : $value;
}
/**
* Like WpValueSanitizer (ruling S4): raw for users with unfiltered_html; otherwise like
* wp_kses_post: NUL bytes, <script> blocks and tags outside a small allow list go.
*/
public function html(string $value): string
{
if ($this->unfiltered) {
return $value;
}
$value = (string) preg_replace('#<script\b[^>]*>.*?</script>#is', '', str_replace(chr(0), '', $value));
return (string) preg_replace(
'#</?(?!(?:p|b|i|em|strong|a|br|span|h[1-6]|ul|ol|li)\b)[a-z][^>]*>#i',
'',
$value
);
}
public function code(string $value): string
{
return $value;
}
/**
* Like esc_url_raw: spaces become %20, characters outside its allow list (tags, quotes,
* braces, NUL) go, %0d/%0a are removed repeatedly, javascript: gives ''.
*/
public function url(string $value): string
{
$value = str_replace(' ', '%20', trim($value));
$value = (string) preg_replace('|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\x80-\xff]|i', '', $value);
do {
$before = $value;
$value = str_replace(['%0d', '%0a', '%0D', '%0A'], '', $value);
} while ($value !== $before);
return str_starts_with(strtolower($value), 'javascript:') ? '' : $value;
}
public function allowsUnfilteredHtml(): bool
{
return $this->unfiltered;
}
/** Every post is readable but 666 (ruling S37, select_post settings). */
public function canReadPost(int $id): bool
{
return $id !== 666;
}
};
}
public static function media(): MediaResolver
{
return new class implements MediaResolver {
public function image(int $id): ?array
{
return match ($id) {
33 => ['url' => 'https://kunde.test/wp-content/uploads/team.jpg', 'alt' => 'Unser Team'],
12 => ['url' => 'https://kunde.test/wp-content/uploads/halle.jpg', 'alt' => ''],
// Alt texts carrying a reference, whole or NUL-split (ruling S33b: dropped).
44 => ['url' => 'https://kunde.test/wp-content/uploads/a.jpg', 'alt' => ComposeFixture::REFERENCE_ALT],
// "<" + VT/FF: strip_tags keeps it as text – a later strip would build the tag (S32a).
46 => ['url' => 'https://kunde.test/wp-content/uploads/c.jpg', 'alt' => '<' . chr(11)
. 'img src=x onerror=alert(1)>Team'],
45 => ['url' => 'https://kunde.test/wp-content/uploads/b.jpg', 'alt' => '$variable' . chr(0)
. substr(ComposeFixture::REFERENCE_ALT, strlen('$variable'))],
default => null,
};
}
};
}
public static function placeholders(): Placeholders
{
return new Placeholders(DiviFixtures::designSystem());
}
public static function attrWriter(bool $unfiltered = false): AttrWriter
{
return new AttrWriter(DiviFixtures::catalog(), self::placeholders(), self::sanitizer($unfiltered));
}
/**
* @param array<string, array<string, array<string, mixed>>>|null $modules Short form per
* type instead of ShortformMap (to reach paths the curated map never names).
*/
public static function shortform(bool $unfiltered = false, ?array $modules = null): ShortformWriter
{
return new ShortformWriter(self::placeholders(), self::media(), self::attrWriter($unfiltered), $modules);
}
public static function rules(): StructureRules
{
return new StructureRules(DiviFixtures::catalog());
}
/**
* Builder version 5.2.1. $design replaces the sample design system (one Placeholders instance
* shared by AttrWriter and ShortformWriter, like the live wiring – ruling S31).
*/
public static function composer(bool $unfiltered = false, ?DesignSystem $design = null): Composer
{
$design ??= DiviFixtures::designSystem();
$placeholders = new Placeholders($design);
$attrWriter = new AttrWriter(DiviFixtures::catalog(), $placeholders, self::sanitizer($unfiltered));
return new Composer(
DiviFixtures::catalog(),
self::rules(),
new ShortformWriter($placeholders, self::media(), $attrWriter),
$attrWriter,
$design,
'5.2.1'
);
}
}