Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php
T
janniclas.loosenandClaude Opus 5.5 26108bc9a9 feat: short-form level for every module with a heading title (trial)
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
2026-09-26 19:20:23 +02:00

243 lines
12 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);
}
}
}
}