feat: GroupKeys und PresetAssignment – Preset-Stapel und Gruppen-Presets über eine Engstelle

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-18 16:45:29 +02:00
co-authored by Claude Sonnet 5.5
parent 6546e23e7f
commit fafa140546
6 changed files with 796 additions and 17 deletions
@@ -0,0 +1,341 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Closure;
use LIT\MCP\Integrations\Divi\Design\PresetAssignment;
use LIT\MCP\Integrations\Divi\Design\PresetBook;
use PHPUnit\Framework\TestCase;
final class PresetAssignmentTest extends TestCase
{
private const PATH = 'items[0]';
/** @var int How often the book closure ran. */
private int $bookCalls = 0;
/** Divi's stored format (formats §3): two module presets, group presets with two names the same ("Doppelt"). */
private static function stored(): array
{
$module = static fn (string $id, string $name, array $attrs = []): array => [
'type' => 'module', 'id' => $id, 'name' => $name, 'moduleName' => 'divi/blurb', 'attrs' => $attrs,
];
$group = static fn (string $id, string $name, string $groupName, string $groupId, array $attrs = []): array => [
'type' => 'group', 'id' => $id, 'name' => $name, 'groupName' => $groupName, 'groupId' => $groupId,
'moduleName' => 'divi/blurb', 'attrs' => $attrs,
];
return [
'module' => ['divi/blurb' => ['default' => '', 'items' => [
'bl00000001' => $module('bl00000001', 'Leistung', [
'title' => ['decoration' => ['font' => ['font' => ['desktop' => ['value' => ['weight' => '700']]]]]],
]),
'bl00000002' => $module('bl00000002', 'Karte'),
'bl00000003' => $module('bl00000003', 'Doppelt'),
'bl00000004' => $module('bl00000004', 'doppelt'),
'ba!d000005' => $module('ba!d000005', 'Kaputt'),
]]],
'group' => [
'divi/font-body' => ['default' => '', 'items' => [
'fb00000001' => $group('fb00000001', 'Fließtext', 'divi/font-body', 'content.decoration.bodyFont'),
'fb00000002' => $group('fb00000002', 'Fließtext groß', 'divi/font-body', 'content.decoration.bodyFont'),
'fb00000003' => $group('fb00000003', 'Doppelt', 'divi/font-body', 'content.decoration.bodyFont'),
'fb00000004' => $group('fb00000004', 'Doppelt', 'divi/font-body', 'content.decoration.bodyFont'),
]],
'divi/spacing' => ['default' => '', 'items' => [
'sp00000001' => $group('sp00000001', 'Abstand Karte', 'divi/spacing', 'module.decoration.spacing'),
]],
'divi/font' => ['default' => '', 'items' => [
'ft00000001' => $group('ft00000001', 'Titelschrift', 'divi/font', 'title.decoration.font'),
]],
],
];
}
private function assignment(?Closure $book = null): PresetAssignment
{
return new PresetAssignment(
$book ?? function (): PresetBook {
++$this->bookCalls;
return PresetBook::fromDivi(self::stored());
},
static fn (string $module): array => $module === 'divi/blurb' ? [
'title.decoration.font' => 'divi/font',
'content.decoration.bodyFont' => 'divi/font-body',
'module.decoration.spacing' => 'divi/spacing',
'button.decoration.spacing' => 'divi/spacing',
] : [],
static fn (string $id): bool => preg_match('/^[0-9a-z]+$/D', $id) === 1
);
}
public function testSingleNameOrIdBecomesAStackOfOne(): void
{
$byName = $this->assignment()->apply('divi/blurb', ['x' => 1], ['preset' => 'LEISTUNG'], self::PATH);
$byId = $this->assignment()->apply('divi/blurb', [], ['preset' => 'bl00000002'], self::PATH);
self::assertSame([], $byName['errors']);
self::assertSame(['x' => 1, 'modulePreset' => ['bl00000001']], $byName['attrs']);
self::assertSame(['modulePreset' => ['bl00000002']], $byId['attrs']);
}
public function testListKeepsTheOrderOfTheStack(): void
{
$result = $this->assignment()->apply(
'divi/blurb',
['modulePreset' => ['alt']],
['preset' => ['Karte', 'bl00000001']],
self::PATH
);
self::assertSame([], $result['errors']);
self::assertSame(['modulePreset' => ['bl00000002', 'bl00000001']], $result['attrs']);
}
public function testNullAndEmptyStringRemoveThePresetWithoutLoadingTheBook(): void
{
foreach ([null, ''] as $preset) {
$result = $this->assignment()->apply(
'divi/blurb',
['modulePreset' => ['bl00000001'], 'builderVersion' => '5.2.1'],
['preset' => $preset],
self::PATH
);
self::assertSame([], $result['errors']);
self::assertSame(['builderVersion' => '5.2.1'], $result['attrs']);
}
self::assertSame(0, $this->bookCalls);
}
public function testSpecWithoutPresetKeysChangesNothing(): void
{
$attrs = ['modulePreset' => ['bl00000001']];
$result = $this->assignment()->apply('divi/blurb', $attrs, ['label' => 'egal'], self::PATH);
self::assertSame(['attrs' => $attrs, 'errors' => []], $result);
self::assertSame(0, $this->bookCalls);
}
public function testStackErrorsLeaveTheStackUntouched(): void
{
$attrs = ['modulePreset' => ['alt']];
$assignment = $this->assignment();
$unknown = $assignment->apply('divi/blurb', $attrs, ['preset' => 'Gibt es nicht'], self::PATH);
self::assertSame(
['items[0].preset' => 'unbekanntes Preset; vorhanden für divi/blurb: Leistung, Karte, Doppelt, doppelt, Kaputt'],
$unknown['errors']
);
self::assertSame($attrs, $unknown['attrs']);
$ambiguous = $assignment->apply('divi/blurb', $attrs, ['preset' => ['Karte', 'Doppelt']], self::PATH);
self::assertSame(
['items[0].preset.1' => 'Preset-Name "Doppelt" ist mehrdeutig; bitte die ID angeben: '
. 'bl00000003, bl00000004.'],
$ambiguous['errors']
);
self::assertSame($attrs, $ambiguous['attrs']);
$duplicate = $assignment->apply('divi/blurb', $attrs, ['preset' => ['Karte', 'bl00000001', 'bl00000002']], self::PATH);
self::assertSame(['items[0].preset.2' => 'Preset "bl00000002" steht mehrfach im Stapel'], $duplicate['errors']);
$invalid = $assignment->apply('divi/blurb', $attrs, ['preset' => 'Kaputt'], self::PATH);
self::assertSame(['items[0].preset' => 'Preset-ID "ba!d000005" ist ungültig'], $invalid['errors']);
self::assertSame($attrs, $invalid['attrs']);
}
public function testStackShapeErrors(): void
{
$assignment = $this->assignment();
$empty = $assignment->apply('divi/blurb', [], ['preset' => []], self::PATH);
self::assertSame(['items[0].preset' => 'leere Liste – null entfernt das Preset'], $empty['errors']);
$tooMany = $assignment->apply('divi/blurb', [], ['preset' => array_fill(0, 11, 'Karte')], self::PATH);
self::assertSame(['items[0].preset' => 'höchstens 10 Presets im Stapel'], $tooMany['errors']);
$notText = $assignment->apply('divi/blurb', [], ['preset' => ['Karte', 5]], self::PATH);
self::assertSame(['items[0].preset.1' => 'Preset-Name oder -ID als Text erwartet'], $notText['errors']);
$object = $assignment->apply('divi/blurb', [], ['preset' => ['a' => 'Karte']], self::PATH);
self::assertSame(['items[0].preset' => 'Text, Liste von Texten oder null erwartet'], $object['errors']);
$number = $assignment->apply('divi/blurb', [], ['preset' => 7], self::PATH);
self::assertSame(['items[0].preset' => 'Text, Liste von Texten oder null erwartet'], $number['errors']);
}
public function testGroupPresetsByShortNameFullNameAndPath(): void
{
$result = $this->assignment()->apply('divi/blurb', ['builderVersion' => '5.2.1'], ['group_presets' => [
'font-body' => 'Fließtext',
'divi/font' => 'ft00000001',
'button.decoration.spacing' => ['Abstand Karte'],
]], self::PATH);
self::assertSame([], $result['errors']);
self::assertSame(
[
'builderVersion' => '5.2.1',
'groupPreset' => [
'content.decoration.bodyFont' => ['presetId' => ['fb00000001'], 'groupName' => 'divi/font-body'],
'title.decoration.font' => ['presetId' => ['ft00000001'], 'groupName' => 'divi/font'],
'button.decoration.spacing' => ['presetId' => ['sp00000001'], 'groupName' => 'divi/spacing'],
],
],
$result['attrs']
);
}
public function testGroupPresetListIsAStackAndOtherGroupsStay(): void
{
$attrs = ['groupPreset' => [
'title.decoration.font' => ['presetId' => ['ft00000001'], 'groupName' => 'divi/font'],
]];
$result = $this->assignment()->apply(
'divi/blurb',
$attrs,
['group_presets' => ['font-body' => ['Fließtext', 'Fließtext groß']]],
self::PATH
);
self::assertSame([], $result['errors']);
self::assertSame(['fb00000001', 'fb00000002'], $result['attrs']['groupPreset']['content.decoration.bodyFont']['presetId']);
self::assertSame($attrs['groupPreset']['title.decoration.font'], $result['attrs']['groupPreset']['title.decoration.font']);
}
public function testNullRemovesTheGroupEntryAndAnEmptyGroupPresetDisappears(): void
{
$attrs = ['groupPreset' => [
'title.decoration.font' => ['presetId' => ['ft00000001'], 'groupName' => 'divi/font'],
'content.decoration.bodyFont' => ['presetId' => ['fb00000001'], 'groupName' => 'divi/font-body'],
]];
$one = $this->assignment()->apply('divi/blurb', $attrs, ['group_presets' => ['font' => null]], self::PATH);
self::assertSame(['content.decoration.bodyFont'], array_keys($one['attrs']['groupPreset']));
$both = $this->assignment()->apply(
'divi/blurb',
$attrs,
['group_presets' => ['font' => null, 'font-body' => null]],
self::PATH
);
self::assertSame([], $both['errors']);
self::assertSame([], $both['attrs']);
}
public function testGroupKeyErrors(): void
{
$assignment = $this->assignment();
$unknown = $assignment->apply('divi/blurb', [], ['group_presets' => ['sizing' => 'X']], self::PATH);
self::assertSame(
['items[0].group_presets.sizing' => 'keine Gruppe "sizing" in divi/blurb; vorhanden: font, font-body, spacing'],
$unknown['errors']
);
$ambiguous = $assignment->apply('divi/blurb', [], ['group_presets' => ['spacing' => 'Abstand Karte']], self::PATH);
self::assertSame(
['items[0].group_presets.spacing' => 'Gruppe "spacing" ist mehrdeutig; bitte den Attributpfad angeben: '
. 'module.decoration.spacing, button.decoration.spacing.'],
$ambiguous['errors']
);
$missing = $assignment->apply('divi/divider', [], ['group_presets' => ['font-body' => 'Fließtext']], self::PATH);
self::assertSame(
['items[0].group_presets.font-body' => 'keine Gruppe "font-body" in divi/divider; vorhanden: (keine)'],
$missing['errors']
);
}
public function testGroupPresetValueErrors(): void
{
$assignment = $this->assignment();
$result = $assignment->apply('divi/blurb', [], ['group_presets' => [
'font-body' => 'Gibt es nicht',
'font' => 7,
'module.decoration.spacing' => [],
]], self::PATH);
self::assertSame(
[
'items[0].group_presets.font-body' => 'unbekanntes Gruppen-Preset "Gibt es nicht" (divi/font-body)',
'items[0].group_presets.font' => 'Text, Liste von Texten oder null erwartet',
'items[0].group_presets.module.decoration.spacing' => 'leere Liste – null entfernt das Gruppen-Preset',
],
$result['errors']
);
self::assertSame([], $result['attrs']);
$ambiguous = $assignment->apply('divi/blurb', [], ['group_presets' => ['font-body' => 'Doppelt']], self::PATH);
self::assertSame(
['items[0].group_presets.font-body' => 'Preset-Name "Doppelt" ist mehrdeutig; bitte die ID angeben: '
. 'fb00000003, fb00000004.'],
$ambiguous['errors']
);
$duplicate = $assignment->apply(
'divi/blurb',
[],
['group_presets' => ['font-body' => ['Fließtext', 'fb00000001']]],
self::PATH
);
self::assertSame(
['items[0].group_presets.font-body' => 'Preset "fb00000001" steht mehrfach im Stapel'],
$duplicate['errors']
);
$shape = $assignment->apply('divi/blurb', [], ['group_presets' => 'font-body'], self::PATH);
self::assertSame(['items[0].group_presets' => 'Objekt Gruppe → Preset erwartet'], $shape['errors']);
}
public function testModulePresetFromAnotherModuleIsUnknown(): void
{
$result = $this->assignment()->apply('divi/text', [], ['preset' => 'Leistung'], self::PATH);
self::assertSame(['items[0].preset' => 'unbekanntes Preset; vorhanden für divi/text: (keine)'], $result['errors']);
}
public function testBookIsLoadedLazilyAndAtMostOnce(): void
{
$assignment = $this->assignment();
self::assertSame(0, $this->bookCalls);
$assignment->apply('divi/blurb', [], ['preset' => 'Karte', 'group_presets' => ['font' => 'Titelschrift']], self::PATH);
$assignment->names();
$assignment->clearOverrides(['modulePreset' => ['bl00000001']]);
$assignment->overrides()->find(['modulePreset' => ['bl00000001']]);
self::assertSame(1, $this->bookCalls);
}
public function testNamesListsEveryPreset(): void
{
$names = $this->assignment()->names();
self::assertSame('Leistung', $names['bl00000001']);
self::assertSame('Fließtext groß', $names['fb00000002']);
self::assertSame('Titelschrift', $names['ft00000001']);
self::assertCount(11, $names);
}
public function testClearOverridesAndOverridesShareTheSameBook(): void
{
$assignment = $this->assignment();
$local = [
'modulePreset' => ['bl00000001'],
'title' => ['decoration' => ['font' => ['font' => ['desktop' => ['value' => ['weight' => '400']]]]]],
];
self::assertSame(
['bl00000001' => ['title.decoration.font.font|desktop|value|weight']],
$assignment->overrides()->find($local)
);
self::assertSame($assignment->overrides(), $assignment->overrides());
self::assertSame(['modulePreset' => ['bl00000001']], $assignment->clearOverrides($local));
}
}