*/ private static function attrs(mixed $loop): array { return ['module' => ['advanced' => ['loop' => $loop]], 'builderVersion' => '5.15.1']; } /** * @param array $value desktop.value of the loop * @return array */ 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 = ''; return NodeIndex::build((new Parser())->parse( '' . '' // s0.r0.c0: a post loop, a term loop group inside it, a disabled loop group inside that. . '' . $text . '' . $text . '' . $text . '' . '' . '' // s0.r0.c1: no loop; a global module instance claiming a loop (opaque: not read). . '' . $text . '' . '' . '' // s1: the section itself is a user loop. . '' . '' . $text . '' . '' . '' )); } 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()); } }