contact-form, cta, toggle, accordion-item, slide, number-counter and team-member (name) take level like heading/blurb; paths from Divi's conversion outlines, checked live with divi-shortform-check.php. A test keeps the guide's field table in sync with ShortformMap. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
243 lines
12 KiB
PHP
243 lines
12 KiB
PHP
<?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);
|
||
}
|
||
}
|
||
}
|
||
}
|