Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php
T
janniclas.loosenandClaude Opus 5.5 b1dd35abd8 feat: Divi tools edit Theme Builder layouts; visible layouts need confirm
TB layouts load through ThemeBuilderAccess (P12), live layouts (attached +
enabled, Q8) need a dry run and confirm (Q9) and get the shared restore point
of VisibleEdits, extracted from GlobalEdits (Q2: backup when revisions are
off, P9 refusals, P14 naming). divi/post-content only in body layouts (F22),
also for nested stored nodes. SaveScope::ThemeBuilderLayout sets Divi flags
and clears site CSS + TB cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 15:45:07 +02:00

251 lines
13 KiB
PHP
Raw 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\Schema;
use LIT\MCP\Integrations\Divi\Schema\AttrPathIndex;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
final class ModuleCatalogTest extends TestCase
{
public function testKnowsModulesTitlesCategoriesAndChildren(): void
{
$catalog = DiviFixtures::catalog();
self::assertTrue($catalog->has('divi/button'));
self::assertFalse($catalog->has('button'));
self::assertSame('divi/button', ModuleCatalog::normalize(' Button '));
self::assertSame('divi/accordion-item', ModuleCatalog::normalize('divi/accordion-item'));
self::assertSame('structure', $catalog->category('divi/section'));
self::assertSame(['divi/accordion-item'], $catalog->childrenOf('divi/accordion'));
self::assertSame(['divi/column'], $catalog->childrenOf('divi/row'));
self::assertContains(
['name' => 'divi/heading', 'title' => 'Heading', 'category' => 'module', 'children' => []],
$catalog->summary()
);
}
public function testDerivesAttributePathsFromSettingsAndDefaults(): void
{
$catalog = DiviFixtures::catalog();
foreach (
[
'button.innerContent', 'button.decoration.button', 'button.decoration.background',
'module.advanced.alignment', 'module.decoration.spacing', 'module.meta.adminLabel',
'module.advanced.text.text',
] as $path
) {
self::assertTrue($catalog->isKnownPath('divi/button', $path), $path);
}
self::assertTrue($catalog->isKnownPath('divi/heading', 'title.decoration.font.font'));
self::assertFalse($catalog->isKnownPath('divi/heading', 'title.decoration.font'));
self::assertTrue($catalog->isKnownPath('divi/text', 'content.decoration.bodyFont.body.font'));
self::assertTrue($catalog->isKnownPath('divi/accordion-item', 'module.advanced.open'));
self::assertTrue($catalog->isKnownPath('divi/column', 'module.decoration.sizing'));
self::assertFalse($catalog->isKnownPath('divi/button', 'button.innerContent.text'));
self::assertSame('title.decoration.font.font', $catalog->suggestions('divi/heading', 'title.decoration.fnt.font')[0]);
}
public function testPathIndexFromHandwrittenMetadata(): void
{
$paths = AttrPathIndex::paths(
[
'title' => ['settings' => [
'innerContent' => ['groupType' => 'group-item', 'item' => ['attrName' => 'title.innerContent']],
'decoration' => ['font' => [], 'groupType' => 'x'],
]],
'module' => ['settings' => [
'meta' => ['meta' => []],
'advanced' => ['text' => ['item' => ['attrName' => 'module.advanced.alignment']]],
]],
],
['module' => ['advanced' => ['open' => ['desktop' => ['value' => 'off']]]], 'builderVersion' => '5']
);
self::assertSame(
[
'module.advanced.alignment', 'module.advanced.open', 'module.advanced.text.text',
'module.advanced.text.textShadow', 'module.meta.adminLabel', 'title.decoration.font.font',
'title.decoration.font.textShadow', 'title.innerContent',
],
$paths
);
}
public function testEveryShortformPathIsKnownForTheSampledModules(): void
{
$catalog = DiviFixtures::catalog();
foreach (['text', 'heading', 'button', 'image', 'blurb', 'code', 'accordion-item'] as $type) {
foreach (ShortformMap::fields($type) as $field => $spec) {
self::assertTrue(
$catalog->isKnownPath('divi/' . $type, $spec['path']),
sprintf('%s.%s → %s', $type, $field, $spec['path'])
);
}
}
}
public function testShortformTableIsConsistent(): void
{
$kinds = ['text', 'html', 'code', 'url', 'media', 'enum', 'onoff', 'level', 'number', 'ids', 'color'];
foreach (ShortformMap::types() as $type) {
$fields = ShortformMap::fields($type);
$primary = ShortformMap::primary($type);
self::assertTrue($primary === null || isset($fields[$primary]), $type . ' primary');
$child = ShortformMap::childType($type);
self::assertTrue($child === null || ShortformMap::has($child), $type . ' child');
foreach ($fields as $field => $spec) {
self::assertContains($spec['kind'], $kinds, $type . '.' . $field);
self::assertSame(1, preg_match('/^[a-zA-Z]+(\.[a-zA-Z]+)+$/', $spec['path']), $type . '.' . $field);
self::assertSame($spec['kind'] === 'media', isset($spec['keys']), $type . '.' . $field);
self::assertSame($spec['kind'] === 'enum', isset($spec['values']), $type . '.' . $field);
}
}
self::assertSame(
['text' => 'Termin', 'url' => '/kontakt'],
ShortformMap::values('button', ['button' => ['innerContent' => ['desktop' => ['value' => [
'text' => 'Termin', 'linkUrl' => '/kontakt',
]]]]])
);
}
public function testDerivesAttributePathKindsFromComponentMetadataWherePossible(): void
{
$catalog = DiviFixtures::catalog();
self::assertSame('richtext', $catalog->pathKind('divi/text', 'content.innerContent'));
self::assertSame('text', $catalog->pathKind('divi/heading', 'title.innerContent'));
self::assertSame('code', $catalog->pathKind('divi/code', 'content.innerContent'));
self::assertSame('text', $catalog->pathKind('divi/button', 'module.meta.adminLabel'));
// Structural/style groups (fonts, backgrounds, …) carry no single scalar component in
// Divi's metadata; 2b falls back to key-name routing for them (spec §5.6, ruling R3).
self::assertSame('unknown', $catalog->pathKind('divi/button', 'button.decoration.background'));
self::assertSame('unknown', $catalog->pathKind('divi/button', 'no.such.path'));
// divi/image has no explicit attrName for its innerContent sub-fields at all.
self::assertSame('unknown', $catalog->pathKind('divi/image', 'image.innerContent'));
// Every kind in the module's kinds() map is a string, keyed by a known attribute path.
$paths = $catalog->paths('divi/heading');
foreach ($catalog->kinds('divi/heading') as $path => $kind) {
self::assertIsString($kind);
self::assertContains($path, $paths);
}
}
public function testLeavesCompositeAttributePathsUnknownRatherThanGuessing(): void
{
$catalog = DiviFixtures::catalog();
// button.innerContent's only attrName-annotated node is the "link" sub-group itself
// (component.type "group": link text + linkUrl + linkTarget), and its "text" sub-field
// carries no attrName of its own – Divi's metadata gives no reliable subName => kind
// map here at all, so the whole path stays unknown (never guessed).
self::assertSame('unknown', $catalog->pathKind('divi/button', 'button.innerContent'));
self::assertArrayNotHasKey('button.innerContent', $catalog->kinds('divi/button'));
}
/**
* Composite paths (Divi groups several named sub-fields, "subName", under one attribute
* path) resolve to a subName => kind map wherever the metadata reliably names each
* sub-field's own component (spec §5.6, ruling F1 #1) – asking without a subName, or for
* a subName the metadata never names (e.g. an image's auto-synced "alt"), stays unknown.
*/
public function testDerivesPerSubNameKindsForCompositePaths(): void
{
$catalog = DiviFixtures::catalog();
// imageIcon.innerContent (divi/blurb): useIcon (toggle), icon (icon-picker), src
// (upload, image) each carry attrName + subName + their own field component.
self::assertSame(
['useIcon' => 'onoff', 'icon' => 'icon', 'src' => 'image'],
$catalog->kinds('divi/blurb')['imageIcon.innerContent']
);
self::assertSame('onoff', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent', 'useIcon'));
self::assertSame('icon', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent', 'icon'));
self::assertSame('image', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent', 'src'));
// No subName given, or one Divi's metadata never names: unknown, never guessed.
self::assertSame('unknown', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent'));
self::assertSame('unknown', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent', 'noSuchField'));
// fullwidth-header's scrollDown.decoration.icon: show (toggle), color (color-picker),
// size (range) – same composite pattern one level deeper.
self::assertSame(
['show' => 'onoff', 'color' => 'color', 'size' => 'number'],
$catalog->kinds('divi/fullwidth-header')['scrollDown.decoration.icon']
);
// buttonOne/buttonTwo.innerContent: both sub-fields use the same generic "divi/text"
// field component, but "linkUrl" is a URL, not plain text – only Divi's own
// features.dynamicContent.type tells them apart.
foreach (['buttonOne', 'buttonTwo'] as $attrName) {
self::assertSame('text', $catalog->pathKind('divi/fullwidth-header', $attrName . '.innerContent', 'text'));
self::assertSame('url', $catalog->pathKind('divi/fullwidth-header', $attrName . '.innerContent', 'linkUrl'));
}
// image.innerContent only has an attrName+subName for "src" – the value actually
// carries more keys at runtime (alt, id, titleText, synced by the upload component),
// none of which Divi's metadata names on their own, so they stay unknown rather than
// inheriting "image".
self::assertSame(['src' => 'image'], $catalog->kinds('divi/fullwidth-header')['image.innerContent']);
self::assertSame('image', $catalog->pathKind('divi/fullwidth-header', 'image.innerContent', 'src'));
self::assertSame('unknown', $catalog->pathKind('divi/fullwidth-header', 'image.innerContent', 'alt'));
self::assertSame('unknown', $catalog->pathKind('divi/fullwidth-header', 'image.innerContent'));
// A plain (non-composite) path is unaffected: still a flat string, not a map.
self::assertSame('richtext', $catalog->pathKind('divi/text', 'content.innerContent'));
self::assertIsString($catalog->kinds('divi/text')['content.innerContent']);
}
/**
* Trial feedback: every short-form module with a heading title offers `level` (Divi's
* conversion outlines map header_level/title_level to <title>.decoration.font.font headingLevel).
*/
public function testModulesWithAHeadingTitleOfferLevel(): void
{
$expected = [
'heading' => 'title', 'blurb' => 'title', 'cta' => 'title', 'toggle' => 'title',
'accordion-item' => 'title', 'slide' => 'title', 'contact-form' => 'title',
'number-counter' => 'title', 'team-member' => 'name',
];
foreach ($expected as $type => $element) {
$level = ShortformMap::fields($type)['level'] ?? null;
self::assertNotNull($level, $type);
self::assertSame($element . '.decoration.font.font', $level['path'], $type);
self::assertSame('headingLevel', $level['sub'], $type);
self::assertSame('level', $level['kind'], $type);
}
}
/** The field table of the divi-shortform guide lists every short-form field. */
public function testShortformGuideTableListsEveryField(): void
{
$guide = (string) file_get_contents(dirname(__DIR__, 5) . '/docs/guides/divi-shortform.md');
$rows = array_values(array_filter(
explode("\n", $guide),
static fn (string $line): bool => str_starts_with($line, '| `')
));
foreach (ShortformMap::types() as $type) {
$row = current(array_filter($rows, static fn (string $line): bool => str_contains($line, '`' . $type . '`')));
self::assertIsString($row, $type . ' has a row');
foreach (array_keys(ShortformMap::fields($type)) as $field) {
self::assertStringContainsString('`' . $field . '`', $row, $type . '.' . $field);
}
}
}
public function testThemeBuilderAreasFromDivisAppearance(): void
{
$catalog = DiviFixtures::catalog();
self::assertSame(['body'], $catalog->themeBuilderAreas('divi/post-content'));
self::assertNull($catalog->themeBuilderAreas('divi/text'));
}
}