Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
336 lines
17 KiB
PHP
336 lines
17 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
|
||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use RuntimeException;
|
||
|
||
final class DynamicContentMapTest extends TestCase
|
||
{
|
||
public function testFlattensTheRegistryToOptionNameAndFieldTypes(): void
|
||
{
|
||
$map = DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry());
|
||
|
||
// before/after: rich text (Divi outputs them as HTML; text() would trim "Von ").
|
||
self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['post_title']);
|
||
self::assertSame(
|
||
['before' => 'richtext', 'after' => 'richtext', 'date_format' => 'select', 'custom_date_format' => 'text'],
|
||
$map['post_date']
|
||
);
|
||
self::assertSame(['post_id' => 'select_post'], $map['post_link_url_page']);
|
||
self::assertSame([], $map['post_link_url']);
|
||
self::assertSame('select', $map['post_meta_key']['select_meta_key']);
|
||
// Fields without a readable type become '' – VariableRef's strictest branch (scalar, no markup).
|
||
self::assertSame(['a' => '', 'b' => ''], $map['broken_fields']);
|
||
self::assertSame([], $map['no_fields']);
|
||
self::assertArrayNotHasKey('not_an_array', $map);
|
||
self::assertArrayNotHasKey(7, $map);
|
||
}
|
||
|
||
public function testLoopOptionsNeverEnterTheMap(): void
|
||
{
|
||
$map = DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry());
|
||
|
||
self::assertSame([], array_filter(array_keys($map), static fn (string $name): bool => str_starts_with($name, 'loop_')));
|
||
}
|
||
|
||
public function testPlaceholderOptionsAreAlwaysThere(): void
|
||
{
|
||
// The map replaces VariableRef's default: every {{post:…}} placeholder must stay usable
|
||
// with the settings it writes (before/after), also where the registry lacks the option.
|
||
$map = DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry());
|
||
|
||
foreach (Placeholders::POST as $option) {
|
||
self::assertArrayHasKey($option, $map, $option);
|
||
foreach (array_keys(DynamicContentRules::DEFAULT_DYNAMIC_CONTENT[$option]) as $setting) {
|
||
self::assertArrayHasKey($setting, $map[$option], "$option.$setting");
|
||
}
|
||
}
|
||
// Supplemented with the default settings.
|
||
self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['post_categories']);
|
||
// Live fields win.
|
||
self::assertSame('select', $map['post_date']['date_format']);
|
||
self::assertSame('text', $map['post_excerpt']['words']);
|
||
|
||
$placeholders = new Placeholders(DiviFixtures::designSystem());
|
||
$references = new VariableRef(DiviFixtures::designSystem(), $map);
|
||
foreach (array_keys(Placeholders::POST) as $key) {
|
||
$reference = $placeholders->resolve('{{post:' . $key . '}}');
|
||
self::assertSame(
|
||
$reference,
|
||
$references->canonical($reference, ComposeFixture::sanitizer()),
|
||
$key
|
||
);
|
||
}
|
||
}
|
||
|
||
public function testTheMapWorksInVariableRef(): void
|
||
{
|
||
$references = new VariableRef(DiviFixtures::designSystem(), DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()));
|
||
$sanitizer = ComposeFixture::sanitizer();
|
||
$ref = static fn (string $name, array $settings): string => VariableRef::serialize('content', $name, $settings);
|
||
|
||
// An option only the live registry knows.
|
||
$site = $ref('site_title', ['before' => 'Willkommen bei ', 'after' => '']);
|
||
self::assertSame($site, $references->canonical($site, $sanitizer));
|
||
// before/after are cleaned as rich text (kses for editors) and re-serialized; spaces stay.
|
||
self::assertSame(
|
||
$ref('post_title', ['before' => 'Neu: ', 'after' => '']),
|
||
$references->canonical($ref('post_title', ['before' => '<script>x()</script>Neu: ', 'after' => '']), $sanitizer)
|
||
);
|
||
// Other text fields go through text().
|
||
self::assertSame(
|
||
$ref('post_excerpt', ['words' => '20']),
|
||
$references->canonical($ref('post_excerpt', ['words' => '<b>20</b>']), $sanitizer)
|
||
);
|
||
// Meta keys still obey S29/S30, also through the live map.
|
||
$meta = $ref('post_meta_key', ['select_meta_key' => 'custom_meta__wp_page_template']);
|
||
self::assertRejected(static fn () => $references->canonical($meta, $sanitizer), 'Meta-Schlüssel');
|
||
$ok = $ref('post_meta_key', ['meta_key' => 'telefon']);
|
||
self::assertSame($ok, $references->canonical($ok, $sanitizer));
|
||
// enable_html stays forbidden even though the registry lists it.
|
||
$html = $ref('post_meta_key', ['enable_html' => 'on']);
|
||
self::assertRejected(static fn () => $references->canonical($html, $sanitizer), 'enable_html');
|
||
// Loop content needs a Divi loop of its kind (ruling X5) – this VariableRef has none.
|
||
$loop = $ref('loop_post_title', []);
|
||
self::assertRejected(static fn () => $references->canonical($loop, $sanitizer), 'Schleifen');
|
||
}
|
||
|
||
public function testLiveRegistryForThePost(): void
|
||
{
|
||
$seen = [];
|
||
$map = (new DynamicContentMap(static function (int $postId) use (&$seen): array {
|
||
$seen[] = $postId;
|
||
|
||
return DiviFixtures::dynamicContentRegistry();
|
||
}))->forPost(42);
|
||
|
||
self::assertSame([42], $seen);
|
||
self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['site_title'] ?? null);
|
||
}
|
||
|
||
public function testUnavailableRegistryFallsBackToTheDefault(): void
|
||
{
|
||
self::assertNull((new DynamicContentMap(static fn (): array => []))->forPost(7), 'empty (recursion guard)');
|
||
self::assertNull((new DynamicContentMap(static fn (): string => 'x'))->forPost(7), 'no array');
|
||
|
||
$log = (string) tempnam(sys_get_temp_dir(), 'lit-log');
|
||
$previous = ini_set('error_log', $log);
|
||
try {
|
||
$map = (new DynamicContentMap(static function (): array {
|
||
throw new RuntimeException('filter exploded');
|
||
}))->forPost(7);
|
||
} finally {
|
||
ini_set('error_log', (string) $previous);
|
||
}
|
||
self::assertNull($map);
|
||
self::assertStringContainsString('filter exploded', (string) file_get_contents($log));
|
||
// The message only – no stack trace in the log (review M-4).
|
||
self::assertStringNotContainsString('#0 ', (string) file_get_contents($log));
|
||
self::assertStringNotContainsString('RuntimeException', (string) file_get_contents($log));
|
||
unlink($log);
|
||
|
||
// Without Divi (no DynamicContentOptions class, as in these unit tests).
|
||
self::assertNull((new DynamicContentMap())->forPost(7));
|
||
}
|
||
|
||
public function testOptionsCarryLabelGroupAndSettingTypesWithoutLoops(): void
|
||
{
|
||
$map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||
|
||
$options = $map->options(831);
|
||
|
||
self::assertSame(['post_id' => 'select_post'], $options['post_link_url_page']['settings']);
|
||
self::assertSame('post_meta_key', $options['post_meta_key']['label']);
|
||
self::assertSame('Standard', $options['post_meta_key']['group']);
|
||
self::assertArrayNotHasKey('loop_post_title', $options);
|
||
self::assertNull((new DynamicContentMap(static fn (int $id): array => []))->options(831));
|
||
}
|
||
|
||
public function testOptionsUseTheSameNamesAndTypesAsTheAllowMap(): void
|
||
{
|
||
$map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||
|
||
$options = $map->options(831);
|
||
|
||
self::assertSame(array_keys((array) $map->forPost(831)), array_keys((array) $options));
|
||
$settings = array_map(static fn (array $option): array => $option['settings'], (array) $options);
|
||
self::assertSame($map->forPost(831), $settings);
|
||
// Placeholder options the registry lacks: name as label, no group.
|
||
self::assertSame(['label' => 'post_tags', 'group' => ''], array_slice($options['post_tags'], 0, 2));
|
||
}
|
||
|
||
public function testOptionsNeverNameProtectedMetaKeys(): void
|
||
{
|
||
// A third-party filter may register per-key options (D4 style custom_meta_<key>).
|
||
$registry = DiviFixtures::dynamicContentRegistry() + [
|
||
'custom_meta__edit_lock' => ['label' => '_edit_lock', 'group' => 'Custom', 'fields' => []],
|
||
'custom_meta_custom_meta__secret' => ['label' => 'x', 'group' => 'Custom', 'fields' => []],
|
||
'custom_meta_telefon' => ['label' => 'Telefon', 'group' => 'Custom', 'fields' => []],
|
||
];
|
||
$map = new DynamicContentMap(static fn (int $id): array => $registry);
|
||
|
||
$options = (array) $map->options(831);
|
||
|
||
self::assertArrayNotHasKey('custom_meta__edit_lock', $options);
|
||
self::assertArrayNotHasKey('custom_meta_custom_meta__secret', $options);
|
||
self::assertSame('Telefon', $options['custom_meta_telefon']['label']);
|
||
// The allow map itself is unchanged: VariableRef refuses those names by its own rules (S12/S30).
|
||
self::assertArrayHasKey('custom_meta__edit_lock', (array) $map->forPost(831));
|
||
}
|
||
|
||
public function testTheAllowMapIsUnchangedByTheRawLookupRefactor(): void
|
||
{
|
||
// Snapshot of forPost() for the live-like registry, taken before forPost() moved onto raw()
|
||
// (Task 15): the security allow map must stay exactly the same (rulings S22/S29/S30/S37).
|
||
$before = '{"post_title":{"before":"richtext","after":"richtext"},"post_excerpt":{"before":"richtext",'
|
||
. '"after":"richtext","words":"text","read_more_label":"text"},"post_date":{"before":"richtext",'
|
||
. '"after":"richtext","date_format":"select","custom_date_format":"text"},"post_author":{"before":'
|
||
. '"richtext","after":"richtext","name_format":"select","link":"yes_no_button"},"site_title":{"before":'
|
||
. '"richtext","after":"richtext"},"post_link_url":[],"post_link_url_page":{"post_id":"select_post"},'
|
||
. '"post_featured_image":[],"post_meta_key":{"before":"richtext","after":"richtext","select_meta_key":'
|
||
. '"select","meta_key":"text","enable_html":"yes_no_button"},"broken_fields":{"a":"","b":""},'
|
||
. '"no_fields":[],"post_modified_date":{"before":"richtext","after":"richtext","date_format":"select",'
|
||
. '"custom_date_format":"text"},"post_categories":{"before":"richtext","after":"richtext"},'
|
||
. '"post_tags":{"before":"richtext","after":"richtext"}}';
|
||
|
||
$map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||
$after = $map->forPost(831);
|
||
|
||
self::assertSame(json_decode($before, true), $after);
|
||
self::assertSame($before, json_encode($after));
|
||
}
|
||
|
||
/** Ruling X2: loop_* options stay, grouped by kind – by group, the three meta options by name. */
|
||
public function testLoopFieldsGroupTheLoopOptionsByKind(): void
|
||
{
|
||
$map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||
|
||
self::assertSame(
|
||
[
|
||
'loop_post_title', 'loop_post_date', 'loop_post_featured_image',
|
||
'loop_post_meta_key_manual_custom_field', 'loop_product_title',
|
||
],
|
||
array_keys($map->loopFields(LoopScope::POST))
|
||
);
|
||
self::assertSame(
|
||
['loop_term_name', 'loop_term_meta_key_manual_custom_field'],
|
||
array_keys($map->loopFields(LoopScope::TERM))
|
||
);
|
||
self::assertSame(
|
||
['loop_user_name', 'loop_user_meta_key_manual_custom_field'],
|
||
array_keys($map->loopFields(LoopScope::USER))
|
||
);
|
||
self::assertSame(['loop_menu_text'], array_keys($map->loopFields(LoopScope::MENU)));
|
||
// ACF repeater rows have no kind (X2/X7e); an unknown kind has no options.
|
||
self::assertSame([], $map->loopFields('repeater_team'));
|
||
self::assertSame([], $map->loopFields(''));
|
||
}
|
||
|
||
public function testLoopFieldsHaveTheShapeOfTheAllowMap(): void
|
||
{
|
||
$map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||
|
||
$post = $map->loopFields(LoopScope::POST);
|
||
|
||
self::assertSame(
|
||
[
|
||
'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
|
||
'date_format' => 'select', 'custom_date_format' => 'text',
|
||
],
|
||
$post['loop_post_date']
|
||
);
|
||
self::assertSame(['loop_position' => 'text', 'thumbnail_size' => 'select'], $post['loop_post_featured_image']);
|
||
self::assertSame(
|
||
['select', 'text', 'yes_no_button'],
|
||
[
|
||
$post['loop_post_meta_key_manual_custom_field']['select_loop_meta_key'],
|
||
$post['loop_post_meta_key_manual_custom_field']['loop_meta_key'],
|
||
$post['loop_post_meta_key_manual_custom_field']['enable_html'],
|
||
]
|
||
);
|
||
}
|
||
|
||
/** The custom field groups are translated ("Loop %s Custom Fields", esc_html__) – their name decides. */
|
||
public function testTheMetaOptionsFollowTheirNameNotTheirGroup(): void
|
||
{
|
||
$registry = DiviFixtures::dynamicContentRegistry();
|
||
$registry['loop_user_meta_key_manual_custom_field']['group'] = 'Schleife: Benutzer-Felder';
|
||
$registry['loop_term_meta_key_manual_custom_field']['group'] = 'Loop';
|
||
$registry['loop_post_meta_key_manual_custom_field']['group'] = 'Loop Users';
|
||
$map = new DynamicContentMap(static fn (int $id): array => $registry);
|
||
|
||
self::assertArrayHasKey('loop_user_meta_key_manual_custom_field', $map->loopFields(LoopScope::USER));
|
||
self::assertArrayHasKey('loop_term_meta_key_manual_custom_field', $map->loopFields(LoopScope::TERM));
|
||
self::assertArrayHasKey('loop_post_meta_key_manual_custom_field', $map->loopFields(LoopScope::POST));
|
||
self::assertArrayNotHasKey('loop_post_meta_key_manual_custom_field', $map->loopFields(LoopScope::USER));
|
||
}
|
||
|
||
/** The map a loop composition builds (Task 4): forPost() + loopFields() of the loop's kind. */
|
||
public function testLoopFieldsWorkInVariableRefInsideALoopOfTheirKind(): void
|
||
{
|
||
$map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||
$terms = LoopScope::ofQueryType('post_taxonomies');
|
||
$references = new VariableRef(
|
||
DiviFixtures::designSystem(),
|
||
(array) $map->forPost(831) + $map->loopFields((string) $terms->kind(), 831),
|
||
$terms
|
||
);
|
||
$sanitizer = ComposeFixture::sanitizer();
|
||
$ref = static fn (string $name, array $settings): string => VariableRef::serialize('content', $name, $settings);
|
||
|
||
$name = $ref('loop_term_name', ['before' => 'Kategorie: ', 'after' => '', 'loop_position' => '']);
|
||
self::assertSame($name, $references->canonical($name, $sanitizer));
|
||
$title = $ref('post_title', ['before' => '', 'after' => '']);
|
||
self::assertSame($title, $references->canonical($title, $sanitizer));
|
||
self::assertRejected(
|
||
static fn () => $references->canonical($ref('loop_post_title', []), $sanitizer),
|
||
'passt nicht zur Schleife dieses Elements (Typ: Terme)'
|
||
);
|
||
self::assertRejected(
|
||
static fn () => $references->canonical(
|
||
$ref('loop_term_meta_key_manual_custom_field', ['enable_html' => 'on']),
|
||
$sanitizer
|
||
),
|
||
'enable_html'
|
||
);
|
||
}
|
||
|
||
public function testLoopFieldsReadTheRegistryOfThePostAndAreEmptyWithoutIt(): void
|
||
{
|
||
$seen = [];
|
||
$map = new DynamicContentMap(static function (int $postId) use (&$seen): array {
|
||
$seen[] = $postId;
|
||
|
||
return DiviFixtures::dynamicContentRegistry();
|
||
});
|
||
|
||
$map->loopFields(LoopScope::POST, 42);
|
||
$map->loopFields(LoopScope::TERM);
|
||
|
||
self::assertSame([42, 0], $seen);
|
||
self::assertSame([], (new DynamicContentMap(static fn (): array => []))->loopFields(LoopScope::POST));
|
||
self::assertSame([], (new DynamicContentMap())->loopFields(LoopScope::POST), 'without Divi');
|
||
}
|
||
|
||
private static function assertRejected(callable $call, string $fragment): void
|
||
{
|
||
try {
|
||
$call();
|
||
self::fail('InvalidArgumentException expected');
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString($fragment, $e->getMessage());
|
||
}
|
||
}
|
||
}
|