Files

336 lines
17 KiB
PHP
Raw Permalink 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\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());
}
}
}