Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php
T
janniclas.loosenandClaude Opus 5.5 73433ae862 feat: per-subName attribute-path kinds for composite Divi paths
lit/divi-catalog's path_kinds previously named one kind per whole attribute
path, built from Divi's metadata component name alone. For a composite path
(Divi groups several named sub-fields, "subName", under one path – e.g.
buttonOne.innerContent holds "text" and "linkUrl") that was wrong: the whole
path got one guessed kind (or none), even though the sub-fields can differ
(fullwidth-header's buttonOne/buttonTwo.innerContent -> "text", though it
also carries a "linkUrl"; image.innerContent -> "image", though the value
also carries alt/id/titleText the metadata never names on its own).

AttrPathIndex::kinds() now resolves a composite path to a map
subName => kind instead, preferring Divi's features.dynamicContent.type over
the (often shared/generic) field component for a named sub-field – this is
what tells a button's plain text sub-field apart from its linkUrl sub-field,
both of which use the same generic "divi/text" component. A plain (non-
composite) path is unaffected. Any subName the metadata never names (or that
disagrees between candidates) is therefore simply absent from the map, and
ModuleCatalog::pathKind() defaults it, like every unresolved path, to
"unknown" - the strictest routing for 2b's sanitizer, never a guess.

ModuleCatalog::pathKind() gains an optional $subName parameter to read a
composite path's map. docs/guides/divi-attrs.md documents the new shape and
that lit-divi-catalog without `module` lists all modules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 13:12:49 +02:00

219 lines
11 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 PHPUnit\Framework\TestCase;
final class ModuleCatalogTest extends TestCase
{
/** Real Divi 5.2.1 metadata (tests/Integration/divi-metadata-fixture.php). */
public static function sampleCatalog(): ModuleCatalog
{
$sample = json_decode(
(string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/metadata-sample.json'),
true
);
return new ModuleCatalog(
$sample['metadata'],
static fn (string $name): array => (array) ($sample['defaults'][$name] ?? [])
);
}
public function testKnowsModulesTitlesCategoriesAndChildren(): void
{
$catalog = self::sampleCatalog();
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 = self::sampleCatalog();
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 = self::sampleCatalog();
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 = self::sampleCatalog();
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 = self::sampleCatalog();
// 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 = self::sampleCatalog();
// 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']);
}
}