feat: DynamicContentRules/VariableRef mit LoopScope – loop_* nur in passender Schleife (X2, X5)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 20:44:26 +02:00
co-authored by Claude Sonnet 5.5
parent 3d12043b4f
commit f8912c255d
4 changed files with 298 additions and 35 deletions
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
@@ -14,9 +15,14 @@ use PHPUnit\Framework\TestCase;
final class VariableRefTest extends TestCase
{
/** @param array<string, array<string, string>>|null $dynamicContent */
private static function ref(?array $dynamicContent = null): VariableRef
private static function ref(?array $dynamicContent = null, ?LoopScope $loop = null): VariableRef
{
return new VariableRef(DiviFixtures::designSystem(), $dynamicContent);
return new VariableRef(DiviFixtures::designSystem(), $dynamicContent, $loop);
}
private static function postLoop(): LoopScope
{
return LoopScope::ofQueryType('post_types');
}
private static function content(string $name, string $settings): string
@@ -24,6 +30,12 @@ final class VariableRefTest extends TestCase
return '$variable({"type":"content","value":{"name":"' . $name . '","settings":' . $settings . '}})$';
}
/** Divi's one post meta loop option (DynamicContentOptionLoopPostMetaKey.php:218-227). */
private static function loopMeta(string $settings): string
{
return self::content('loop_post_meta_key_manual_custom_field', $settings);
}
/** A JSON "\uXXXX" escape, built from bytes so no tool turns it into the character itself. */
private static function escape(string $hex): string
{
@@ -219,7 +231,18 @@ final class VariableRefTest extends TestCase
self::content('post_date', '{"date_format":"d.m.Y","offset":3}'),
$ref->canonical(self::content('post_date', '{"date_format":" d.m.Y","offset":3}'), $sanitizer)
);
self::assertSame(self::content('loop_post_title', '{}'), $ref->canonical(self::content('loop_post_title', '{}'), $sanitizer));
// Ruling X5: a loop_* name the map names still needs a loop of its kind.
try {
$ref->canonical(self::content('loop_post_title', '{}'), $sanitizer);
self::fail('loop_post_title accepted without a loop');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('braucht eine Divi-Schleife', $e->getMessage());
}
$inLoop = self::ref(['loop_post_title' => []], self::postLoop());
self::assertSame(
self::content('loop_post_title', '{}'),
$inLoop->canonical(self::content('loop_post_title', '{}'), $sanitizer)
);
// Names outside the map: custom_meta_ keeps its rule, the rest is unknown.
self::assertSame(
self::content('custom_meta_telefon', '{"before":"<b>T</b>"}'),
@@ -241,6 +264,57 @@ final class VariableRefTest extends TestCase
}
}
/** Ruling X2/X5: loop content in a loop of its kind is cleaned like any other content reference. */
public function testLoopContentInALoopOfItsKind(): void
{
$meta = [
'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
'select_loop_meta_key' => 'select', 'loop_meta_key' => 'text', 'enable_html' => 'yes_no_button',
];
$map = [
'loop_post_date' => [
'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
'date_format' => 'select', 'custom_date_format' => 'text',
],
'loop_post_meta_key_manual_custom_field' => $meta,
'loop_term_meta_key_manual_custom_field' => $meta,
];
$sanitizer = ComposeFixture::sanitizer();
$date = self::content(
'loop_post_date',
'{"before":"Am ","after":"","loop_position":"","date_format":"custom","custom_date_format":"d.m.Y"}'
);
self::assertSame($date, self::ref($map, self::postLoop())->canonical($date, $sanitizer));
self::assertSame(
self::content('loop_post_date', '{"before":"Am ","after":""}'),
self::ref($map, self::postLoop())->canonical(
self::content('loop_post_date', '{"before":"<script>x()</script>Am ","after":""}'),
$sanitizer
)
);
$terms = self::ref($map, LoopScope::ofQueryType('post_taxonomies'));
$termMeta = self::content('loop_term_meta_key_manual_custom_field', '{"select_loop_meta_key":"%s"}');
$farbe = sprintf($termMeta, 'loop_term_meta_key_farbe');
self::assertSame($farbe, $terms->canonical($farbe, $sanitizer));
$rejected = [
'Typ: Terme' => [$terms, self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_telefon"}')],
'Meta-Schlüssel' => [$terms, sprintf($termMeta, 'loop_term_meta_key__x')],
'HTML-Ausgabe' => [self::ref($map, self::postLoop()), self::loopMeta('{"enable_html":"on"}')],
'braucht eine Divi-Schleife' => [self::ref($map), $date],
];
foreach ($rejected as $fragment => [$ref, $value]) {
try {
$ref->canonical($value, $sanitizer);
self::fail('Accepted: ' . $value);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($fragment, $e->getMessage());
}
}
}
/** S28: the split-marker rows really carry no marker before sanitizing. */
public function testSplitMarkerRowsHaveNoRawMarker(): void
{
@@ -258,8 +332,8 @@ final class VariableRefTest extends TestCase
$ref = self::ref([
'post_meta_key' => ['before' => 'text', 'meta_key' => 'text', 'select_meta_key' => 'select'],
'custom_meta__edit_lock' => ['before' => 'text'],
'loop_post_meta_key' => ['loop_meta_key' => 'text'],
]);
'loop_post_meta_key_manual_custom_field' => ['loop_meta_key' => 'text'],
], self::postLoop());
$sanitizer = ComposeFixture::sanitizer();
self::assertSame(
@@ -275,7 +349,7 @@ final class VariableRefTest extends TestCase
self::content('post_meta_key', '{"meta_key":"_wp_page_template"}') => 'meta_key',
self::content('post_meta_key', '{"select_meta_key":"custom_meta__edit_lock"}') => 'select_meta_key',
self::content('post_meta_key', '{"meta_key":"a b"}') => 'meta_key',
self::content('loop_post_meta_key', '{"loop_meta_key":"_secret"}') => 'loop_meta_key',
self::loopMeta('{"loop_meta_key":"_secret"}') => 'loop_meta_key',
self::content('custom_meta__edit_lock', '{}') => 'custom_meta__edit_lock',
];
foreach ($rejected as $value => $fragment) {
@@ -300,20 +374,20 @@ final class VariableRefTest extends TestCase
self::content('post_meta_key', '{"select_meta_key":"custom_meta_custom_meta__wp_page_template"}'),
],
'doubled prefix in meta_key' => [self::content('post_meta_key', '{"meta_key":"custom_meta_custom_meta__x"}')],
'loop post prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key__edit_lock"}')],
'loop user prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key__session_tokens"}')],
'loop term prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key__secret"}')],
'loop post prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key__edit_lock"}')],
'loop user prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key__session_tokens"}')],
'loop term prefix' => [self::loopMeta('{"select_loop_meta_key":"loop_term_meta_key__secret"}')],
'doubled loop prefix' => [
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_loop_post_meta_key__edit_lock"}'),
self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_loop_post_meta_key__edit_lock"}'),
],
'mixed loop and meta prefix' => [
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_custom_meta__edit_lock"}'),
self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_custom_meta__edit_lock"}'),
],
'mixed meta and loop prefix' => [
self::content('post_meta_key', '{"select_meta_key":"custom_meta_loop_term_meta_key__secret"}'),
],
'two loop prefixes' => [
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_loop_post_meta_key_telefon"}'),
self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key_loop_post_meta_key_telefon"}'),
],
];
}
@@ -328,10 +402,12 @@ final class VariableRefTest extends TestCase
{
$ref = self::ref([
'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'],
'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
]);
'loop_post_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
], self::postLoop());
$this->expectException(InvalidArgumentException::class);
// The meta key rule refuses it – not the loop check (the post loop fits the post meta option).
$this->expectExceptionMessageMatches('/Meta-Schlüssel|Unbekannter Name/');
$ref->canonical($value, ComposeFixture::sanitizer());
}
@@ -340,18 +416,17 @@ final class VariableRefTest extends TestCase
{
$ref = self::ref([
'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'],
'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
]);
'loop_post_meta_key_manual_custom_field' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'],
], self::postLoop());
$sanitizer = ComposeFixture::sanitizer();
foreach ([
self::content('custom_meta_telefon', '{}'),
self::content('post_meta_key', '{"select_meta_key":"custom_meta_fax"}'),
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_telefon"}'),
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_first_name"}'),
self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key_farbe"}'),
self::content(
'loop_post_meta_key',
self::loopMeta('{"select_loop_meta_key":"loop_post_meta_key_telefon"}'),
self::loopMeta('{"select_loop_meta_key":"loop_user_meta_key_first_name"}'),
self::loopMeta('{"select_loop_meta_key":"loop_term_meta_key_farbe"}'),
self::loopMeta(
'{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"telefon"}'
),
] as $value) {