Files

217 lines
11 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\Compose;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Compose\LoopContext;
use LIT\MCP\Integrations\Divi\Compose\LoopScope;
use PHPUnit\Framework\TestCase;
/** Ruling X1: the nearest ancestor-or-self with desktop.value.enable === "on" decides (LoopUtils.php:4720). */
final class LoopContextTest extends TestCase
{
/**
* Block attributes with module.advanced.loop.
*
* @param mixed $loop The value of module.advanced.loop
* @return array<string, mixed>
*/
private static function attrs(mixed $loop): array
{
return ['module' => ['advanced' => ['loop' => $loop]], 'builderVersion' => '5.15.1'];
}
/**
* @param array<string, mixed> $value desktop.value of the loop
* @return array<string, mixed>
*/
private static function loop(array $value): array
{
return self::attrs(['desktop' => ['value' => $value]]);
}
public function testOnlyTheStringOnAtDesktopValueEnablesALoop(): void
{
self::assertTrue(LoopContext::enabled(self::loop(['enable' => 'on'])));
$off = [
'off' => self::loop(['enable' => 'off']),
'true' => self::loop(['enable' => true]),
'int 1' => self::loop(['enable' => 1]),
'"1"' => self::loop(['enable' => '1']),
'"true"' => self::loop(['enable' => 'true']),
'"ON"' => self::loop(['enable' => 'ON']),
'missing enable' => self::loop(['queryType' => 'post_types']),
'tablet only' => self::attrs(['tablet' => ['value' => ['enable' => 'on']]]),
'phone only' => self::attrs(['phone' => ['value' => ['enable' => 'on']]]),
'hover only' => self::attrs(['desktop' => ['hover' => ['enable' => 'on']]]),
'sticky only' => self::attrs(['desktop' => ['sticky' => ['enable' => 'on']]]),
'no loop' => ['builderVersion' => '5.15.1'],
'loop is a string' => self::attrs('on'),
'desktop is a string' => self::attrs(['desktop' => 'on']),
'value is a string' => self::attrs(['desktop' => ['value' => 'on']]),
'value is a list' => self::attrs(['desktop' => ['value' => ['on']]]),
'empty JSON object' => self::attrs(new \stdClass()),
'advanced is a string' => ['module' => ['advanced' => 'loop']],
];
foreach ($off as $case => $attrs) {
self::assertFalse(LoopContext::enabled($attrs), $case);
self::assertNull(LoopContext::ownScope($attrs), $case);
}
}
public function testOwnScopeReadsTheQueryTypeWithPostTypesAsDefault(): void
{
self::assertSame('post_types', LoopContext::ownScope(self::loop(['enable' => 'on']))?->queryType());
self::assertSame(LoopScope::POST, LoopContext::ownScope(self::loop(['enable' => 'on']))?->kind());
self::assertSame(
'post_types',
LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => null]))?->queryType()
);
self::assertSame(
LoopScope::TERM,
LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'post_taxonomies']))?->kind()
);
self::assertSame(
LoopScope::USER,
LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'user_roles']))?->kind()
);
self::assertSame(
LoopScope::MENU,
LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'menus']))?->kind()
);
}
public function testAnUnknownOrMalformedQueryTypeIsALoopWithoutKind(): void
{
$repeater = LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => 'repeater_team']));
self::assertNotNull($repeater);
self::assertFalse($repeater->active());
self::assertSame('repeater_team', $repeater->queryType());
// sanitize_key() turns a non-string into "" – no post_types default (LoopUtils.php:730-732 isset()).
$array = LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => ['post_types']]));
self::assertNotNull($array);
self::assertFalse($array->active());
self::assertSame('', $array->queryType());
}
public function testTheInnermostEnabledLoopOfTheChainWins(): void
{
$posts = self::loop(['enable' => 'on', 'queryType' => 'post_types']);
$terms = self::loop(['enable' => 'on', 'queryType' => 'post_taxonomies']);
$plain = ['builderVersion' => '5.15.1'];
$disabled = self::loop(['enable' => 'off', 'queryType' => 'user_roles']);
self::assertSame(LoopScope::TERM, LoopContext::fromChain([$plain, $posts, $terms, $plain])->kind());
self::assertSame(LoopScope::POST, LoopContext::fromChain([$terms, $posts])->kind(), 'post loop inside terms');
self::assertSame(LoopScope::POST, LoopContext::fromChain([$posts, $disabled, $plain])->kind());
self::assertSame(LoopScope::POST, LoopContext::fromChain([$posts])->kind(), 'the node itself');
self::assertNull(LoopContext::fromChain([$plain, $disabled])->queryType());
self::assertNull(LoopContext::fromChain([])->queryType());
// An unsupported inner loop still hides the outer one: Divi resolves the inner loop first.
$repeater = self::loop(['enable' => 'on', 'queryType' => 'repeater_team']);
$inner = LoopContext::fromChain([$posts, $repeater, $plain]);
self::assertFalse($inner->active());
self::assertSame('repeater_team', $inner->queryType());
}
/** Audit M1: a meta key option must fit EVERY enabled loop of the chain (an empty inner loop hands over to the outer). */
public function testMetaKeyOptionsMustFitEveryLoopOfTheChain(): void
{
$posts = self::loop(['enable' => 'on', 'queryType' => 'post_types']);
$terms = self::loop(['enable' => 'on', 'queryType' => 'post_taxonomies']);
$repeater = self::loop(['enable' => 'on', 'queryType' => 'repeater_team']);
$meta = 'loop_post_meta_key_manual_custom_field';
$postInTerm = LoopContext::fromChain([$terms, $posts]);
self::assertSame([LoopScope::POST, LoopScope::TERM], $postInTerm->kinds());
self::assertFalse($postInTerm->fits($meta));
self::assertTrue($postInTerm->fits('loop_post_title'), 'other options keep the innermost rule');
self::assertTrue(LoopContext::fromChain([$posts, $posts])->fits($meta));
self::assertFalse(LoopContext::fromChain([$repeater, $posts])->fits($meta), 'unsupported outer loop');
self::assertTrue(LoopContext::fromChain([self::loop([]), $posts])->fits($meta), 'a disabled loop is no loop');
}
/** Audit L2: Divi sanitize_key()s queryType and knows the aliases terms/users. */
public function testQueryTypeIsNormalisedLikeSanitizeKey(): void
{
$kind = static fn (string $q): ?string => LoopContext::ownScope(self::loop(['enable' => 'on', 'queryType' => $q]))
?->kind();
self::assertSame(LoopScope::TERM, $kind('Post_Taxonomies'));
self::assertSame(LoopScope::TERM, $kind(' post_taxonomies '));
self::assertSame(LoopScope::TERM, $kind('terms'));
self::assertSame(LoopScope::USER, $kind('users'));
self::assertSame(LoopScope::POST, $kind("POST_TYPES\n"));
self::assertNull($kind('repeater_team'));
}
private static function page(): NodeIndex
{
$loop = static fn (string $queryType): string => '{"module":{"advanced":{"loop":{"desktop":{"value":'
. '{"enable":"on","loopId":"loop-lit4etest1","queryType":"' . $queryType . '"}}}}},'
. '"builderVersion":"5.15.1"}';
$off = '{"module":{"advanced":{"loop":{"desktop":{"value":{"enable":"off","queryType":"menus"}},'
. '"tablet":{"value":{"enable":"on"}}}}},"builderVersion":"5.15.1"}';
$text = '<!-- wp:divi/text {"builderVersion":"5.15.1"} /-->';
return NodeIndex::build((new Parser())->parse(
'<!-- wp:divi/placeholder -->'
. '<!-- wp:divi/section {"builderVersion":"5.15.1"} --><!-- wp:divi/row {"builderVersion":"5.15.1"} -->'
// s0.r0.c0: a post loop, a term loop group inside it, a disabled loop group inside that.
. '<!-- wp:divi/column ' . $loop('post_types') . ' -->' . $text
. '<!-- wp:divi/group ' . $loop('post_taxonomies') . ' -->' . $text
. '<!-- wp:divi/group ' . $off . ' -->' . $text . '<!-- /wp:divi/group -->'
. '<!-- /wp:divi/group -->'
. '<!-- /wp:divi/column -->'
// s0.r0.c1: no loop; a global module instance claiming a loop (opaque: not read).
. '<!-- wp:divi/column {"builderVersion":"5.15.1"} -->' . $text
. '<!-- wp:divi/text {"globalModule":"812","module":{"advanced":{"loop":{"desktop":{"value":'
. '{"enable":"on"}}}}},"builderVersion":"5.15.1"} /-->'
. '<!-- /wp:divi/column -->'
. '<!-- /wp:divi/row --><!-- /wp:divi/section -->'
// s1: the section itself is a user loop.
. '<!-- wp:divi/section ' . $loop('user_roles') . ' --><!-- wp:divi/row {"builderVersion":"5.15.1"} -->'
. '<!-- wp:divi/column {"builderVersion":"5.15.1"} -->' . $text . '<!-- /wp:divi/column -->'
. '<!-- /wp:divi/row --><!-- /wp:divi/section -->'
. '<!-- /wp:divi/placeholder -->'
));
}
public function testTheTreeWalksAncestorsOrSelfByPathId(): void
{
$index = self::page();
// The ids the cases below rely on.
self::assertSame('divi/group', $index->find('s0.r0.c0.m1.m1')?->name());
self::assertTrue($index->find('s0.r0.c1.g0')?->isOpaque());
$kinds = [
's0' => null,
's0.r0' => null,
's0.r0.c0' => LoopScope::POST,
's0.r0.c0.m0' => LoopScope::POST,
's0.r0.c0.m1' => LoopScope::TERM,
's0.r0.c0.m1.m0' => LoopScope::TERM,
's0.r0.c0.m1.m1' => LoopScope::TERM,
's0.r0.c0.m1.m1.m0' => LoopScope::TERM,
's0.r0.c1' => null,
's0.r0.c1.m0' => null,
's0.r0.c1.g0' => null,
's1' => LoopScope::USER,
's1.r0.c0.m0' => LoopScope::USER,
'nope' => null,
's9.r0' => null,
];
foreach ($kinds as $id => $kind) {
self::assertSame($kind, LoopContext::of($index, $id)->kind(), $id);
}
self::assertSame('post_taxonomies', LoopContext::of($index, 's0.r0.c0.m1.m1.m0')->queryType());
self::assertNull(LoopContext::of($index, 's0.r0.c1.g0')->queryType());
}
}