Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
226 lines
10 KiB
PHP
226 lines
10 KiB
PHP
<?php
|
||
|
||
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\Placeholders;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class PlaceholdersTest extends TestCase
|
||
{
|
||
/** Loop fields of a post loop as DynamicContentMap::loopFields() gives them (Divi 5.15.1 shapes). */
|
||
private const POST_LOOP_FIELDS = [
|
||
'loop_post_date' => [
|
||
'before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text',
|
||
'date_format' => 'select', 'custom_date_format' => 'text',
|
||
],
|
||
'loop_post_featured_image' => ['loop_position' => 'text', 'thumbnail_size' => 'select'],
|
||
];
|
||
|
||
/**
|
||
* Rows with a third value true go into a URL field (ruling P4: only the link row – URL mode
|
||
* refuses colour references by design).
|
||
*
|
||
* @return array<string, array{0: string, 1: string, 2?: bool}>
|
||
*/
|
||
public static function placeholders(): array
|
||
{
|
||
return [
|
||
'color alias' => ['{{color:primary}}', '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$'],
|
||
'color label' => ['{{ color : Nachtblau }}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
|
||
'color id' => ['{{color:gcid-a1b2}}', '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$'],
|
||
'variable' => ['{{var:Telefon}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'],
|
||
'variable id' => ['{{var:gvid-s1}}', '$variable({"type":"content","value":{"name":"gvid-s1","settings":{}}})$'],
|
||
'font' => ['{{font:heading}}', '$variable({"type":"content","value":{"name":"--et_global_heading_font","settings":{}}})$'],
|
||
'post title' => [
|
||
'{{post:title}}',
|
||
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$',
|
||
],
|
||
'featured image' => ['{{post:featured_image}}', '$variable({"type":"content","value":{"name":"post_featured_image","settings":{}}})$'],
|
||
'custom field' => [
|
||
'{{field:telefon_mobil}}',
|
||
'$variable({"type":"content","value":{"name":"custom_meta_telefon_mobil","settings":{"before":"","after":""}}})$',
|
||
],
|
||
'internal link' => [
|
||
'{{link:839}}',
|
||
'$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$',
|
||
true,
|
||
],
|
||
'plain text' => ['Hallo Welt', 'Hallo Welt'],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider placeholders */
|
||
public function testResolvesToDiviVariables(string $input, string $expected, bool $url = false): void
|
||
{
|
||
$placeholders = ComposeFixture::placeholders();
|
||
$resolved = $placeholders->resolve($input);
|
||
|
||
self::assertSame($expected, $resolved);
|
||
if ($expected !== $input) {
|
||
// What placeholders produce passes the strict checker unchanged (ruling S1/S25); the live
|
||
// registry map knows post_link_url_page, the URL mode allows it.
|
||
$references = new VariableRef(
|
||
DiviFixtures::designSystem(),
|
||
DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())
|
||
);
|
||
self::assertSame($resolved, $references->canonical($resolved, ComposeFixture::sanitizer(), $url));
|
||
}
|
||
}
|
||
|
||
/** @return array<string, array{string, string}> */
|
||
public static function invalid(): array
|
||
{
|
||
return [
|
||
'unknown color' => ['{{color:Lila}}', 'Vorhanden: primary, body, Primärfarbe, Fließtextfarbe, Nachtblau.'],
|
||
'unknown color id' => ['{{color:gcid-nope}}', 'Unbekannte globale Farbe'],
|
||
'unknown variable' => ['{{var:Fax}}', 'Vorhanden: Radius, Telefon'],
|
||
'unknown font' => ['{{font:mono}}', 'heading, body'],
|
||
'unknown post value' => ['{{post:views}}', 'title, excerpt'],
|
||
'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'],
|
||
'protected field key' => ['{{field:_edit_lock}}', 'Ungültiger Feldschlüssel'],
|
||
'embedded' => ['Seite: {{post:title}}', 'allein im Wert'],
|
||
'embedded loop value' => ['Am {{loop:post_date}}', 'post, field, link, loop'],
|
||
'unknown loop value' => ['{{loop:views}}', 'Unbekannter Schleifen-Inhalt "views". Vorhanden: post_title,'],
|
||
'loop value with its prefix' => ['{{loop:loop_post_date}}', 'ohne "loop_"'],
|
||
'loop meta key option' => ['{{loop:post_meta_key_manual_custom_field}}', 'nur in der Vollform'],
|
||
'link without id' => ['{{link:start}}', 'Ungültige Beitrags-ID'],
|
||
'link to a missing post' => ['{{link:999}}', 'Eintrag 999'],
|
||
'link without link targets' => ['{{link:0}}', 'Ungültige Beitrags-ID'],
|
||
'link with a leading zero' => ['{{link:0839}}', 'Ungültige Beitrags-ID'],
|
||
'link beyond PHP_INT_MAX' => ['{{link:9999999999999999999}}', 'Ungültige Beitrags-ID'],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider invalid */
|
||
public function testRejectsUnknownNamesWithTheExistingOnes(string $input, string $message): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage($message);
|
||
|
||
ComposeFixture::placeholders()->resolve($input);
|
||
}
|
||
|
||
/** @return array<string, array{string, string}> */
|
||
public static function ambiguous(): array
|
||
{
|
||
return [
|
||
'color label' => ['{{color:blau}}', 'gcid-b1, gcid-b2'],
|
||
'variable label' => ['{{var:Info}}', 'gvid-i1, gvid-i2'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Ruling S13a: a label two entries share never silently picks the first one; IDs work directly.
|
||
*
|
||
* @dataProvider ambiguous
|
||
*/
|
||
public function testAmbiguousLabelsFailWithTheIds(string $input, string $ids): void
|
||
{
|
||
$placeholders = new Placeholders(new DesignSystem(
|
||
[
|
||
['id' => 'gcid-b1', 'label' => 'Blau', 'value' => '#00f'],
|
||
['id' => 'gcid-b2', 'label' => 'blau', 'value' => '#00a'],
|
||
],
|
||
[
|
||
['id' => 'gvid-i1', 'type' => 'strings', 'label' => 'Info', 'value' => 'a'],
|
||
['id' => 'gvid-i2', 'type' => 'strings', 'label' => 'Info', 'value' => 'b'],
|
||
],
|
||
[],
|
||
['heading' => '', 'body' => ''],
|
||
[]
|
||
));
|
||
|
||
try {
|
||
$placeholders->resolve($input);
|
||
self::fail('Ambiguous label accepted.');
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('mehrdeutig', $e->getMessage());
|
||
self::assertStringContainsString($ids, $e->getMessage());
|
||
}
|
||
|
||
self::assertStringContainsString('"gcid-b2"', $placeholders->resolve('{{color:gcid-b2}}'));
|
||
self::assertStringContainsString('"gvid-i2"', $placeholders->resolve('{{var:gvid-i2}}'));
|
||
}
|
||
|
||
public function testColorTypeVariablesBecomeColorReferences(): void
|
||
{
|
||
$placeholders = new Placeholders(new DesignSystem(
|
||
[],
|
||
[['id' => 'gvid-c1', 'type' => 'colors', 'label' => 'Akzent', 'value' => '#f00']],
|
||
[],
|
||
['heading' => '', 'body' => ''],
|
||
[]
|
||
));
|
||
|
||
self::assertSame(
|
||
'$variable({"type":"color","value":{"name":"gvid-c1","settings":{}}})$',
|
||
$placeholders->resolve('{{var:Akzent}}')
|
||
);
|
||
}
|
||
|
||
public function testResolvesDeepValues(): void
|
||
{
|
||
self::assertSame(
|
||
['color' => '$variable({"type":"color","value":{"name":"gcid-primary-color","settings":{}}})$', 'size' => '20px'],
|
||
ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px'])
|
||
);
|
||
}
|
||
|
||
public function testLinksNeedLinkTargets(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage('Eintrag 839');
|
||
|
||
(new Placeholders(DiviFixtures::designSystem()))->resolve('{{link:839}}');
|
||
}
|
||
|
||
/**
|
||
* Ruling X4: {{loop:<option>}} names the loop option without its prefix; before/after only where the loop
|
||
* fields of the composition's scope give the option those settings (Divi has none on image options).
|
||
*/
|
||
public function testLoopPlaceholdersNameTheLoopOption(): void
|
||
{
|
||
$placeholders = new Placeholders(DiviFixtures::designSystem(), null, self::POST_LOOP_FIELDS);
|
||
|
||
$date = $placeholders->resolve('{{ loop : Post_Date }}');
|
||
self::assertSame(
|
||
'$variable({"type":"content","value":{"name":"loop_post_date","settings":{"before":"","after":""}}})$',
|
||
$date
|
||
);
|
||
self::assertSame(
|
||
'$variable({"type":"content","value":{"name":"loop_post_featured_image","settings":{}}})$',
|
||
$placeholders->resolve('{{loop:post_featured_image}}')
|
||
);
|
||
// Not in the scope's fields (no loop, another kind): no settings – VariableRef names the loop problem.
|
||
self::assertSame(
|
||
'$variable({"type":"content","value":{"name":"loop_term_name","settings":{}}})$',
|
||
$placeholders->resolve('{{loop:term_name}}')
|
||
);
|
||
|
||
// What the placeholder writes passes the checker of a post loop unchanged.
|
||
$references = new VariableRef(
|
||
DiviFixtures::designSystem(),
|
||
DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry()) + self::POST_LOOP_FIELDS,
|
||
LoopScope::ofQueryType('post_types')
|
||
);
|
||
self::assertSame($date, $references->canonical($date, ComposeFixture::sanitizer()));
|
||
}
|
||
|
||
public function testEveryLoopPlaceholderNamesALoopOption(): void
|
||
{
|
||
foreach (Placeholders::LOOP as $short => $option) {
|
||
self::assertSame('loop_' . $short, $option);
|
||
self::assertStringNotContainsString('meta_key', $option, 'meta key options need settings (full form)');
|
||
}
|
||
}
|
||
}
|