Review fix round 1 on the lit/divi-catalog task:
- AttrPathIndex::collectKinds() no longer records a kind from a
component.type === "group" node (a composite of several sub-kinds,
e.g. button.innerContent = text + link + target), and kinds() now
drops any attrName with more than one distinct candidate kind
instead of last-write-wins (e.g. imageIcon.innerContent had onoff +
icon + image sub-fields sharing one attrName). Both previously
produced a wrong-but-present kind, which is more dangerous for 2b's
sanitization routing than "unknown". Verified against the committed
blurb/button/fullwidth-header fixture entries and live against the
full 100-module catalog.
- AttrPathIndex::fromDefaults() now builds on AttrMap::flatten()
instead of a parallel walker.
- ModuleCatalog::shortName() replaces the duplicated
substr($name, strlen('divi/')) in DiviCatalogAbility.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
171 lines
8.0 KiB
PHP
171 lines
8.0 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 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) – not a single scalar kind.
|
||
self::assertSame('unknown', $catalog->pathKind('divi/button', 'button.innerContent'));
|
||
|
||
// imageIcon.innerContent carries three "field" components under the same attrName with
|
||
// different kinds (useIcon: onoff, icon: icon, src: image) – a genuine conflict.
|
||
self::assertSame('unknown', $catalog->pathKind('divi/blurb', 'imageIcon.innerContent'));
|
||
|
||
// fullwidth-header's scrollDown.decoration.icon is the same pattern (onoff/icon/color/number).
|
||
self::assertSame('unknown', $catalog->pathKind('divi/fullwidth-header', 'scrollDown.decoration.icon'));
|
||
}
|
||
}
|