'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' => 'Neu: ', 'after' => '']), $sanitizer) ); // Other text fields go through text(). self::assertSame( $ref('post_excerpt', ['words' => '20']), $references->canonical($ref('post_excerpt', ['words' => '20']), $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: Divi offers it only inside loops – never through the tools. $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_). $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)); } 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()); } } }