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,58 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
defined('ABSPATH') || exit;
/**
* Resolves the key of a "group_presets" entry (ruling R3) to the groupId – the attribute path of the option group in
* the module, e.g. "content.decoration.bodyFont". The key is the short name ("font-body"), the full name
* ("divi/font-body") or the attribute path itself; a short or full name that several groups of the module share must
* be given as path. The groups come from Divi (DesignSystemWriter::groupsOf()), never from the client. Pure PHP.
*/
final class GroupKeys
{
/** Exception code: no group of the module matches the key. */
public const UNKNOWN = 1;
/** Exception code: several groups of the module have that name. */
public const AMBIGUOUS = 2;
/**
* @param array<string, string> $groups groupId => groupName, e.g. "module.decoration.spacing" => "divi/spacing"
* @return string The groupId.
* @throws InvalidArgumentException German message; code UNKNOWN or AMBIGUOUS. The UNKNOWN message does not name
* the module – the caller adds it.
*/
public static function resolve(array $groups, string $key): string
{
if (array_key_exists($key, $groups)) {
return (string) $key;
}
$paths = [];
foreach ($groups as $groupId => $name) {
if ($key === $name || 'divi/' . $key === $name) {
$paths[] = (string) $groupId;
}
}
if ($paths === []) {
throw new InvalidArgumentException(sprintf('keine Gruppe "%s"', $key), self::UNKNOWN);
}
if (count($paths) > 1) {
throw new InvalidArgumentException(sprintf(
'Gruppe "%s" ist mehrdeutig; bitte den Attributpfad angeben: %s.',
$key,
implode(', ', $paths)
), self::AMBIGUOUS);
}
return $paths[0];
}
}
@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
defined('ABSPATH') || exit;
/**
* The one place that turns the client's "preset" and "group_presets" into the attributes Divi stores on a block
* (D7, rulings R2/R3): modulePreset (a stack of preset IDs) and groupPreset ({groupId: {presetId, groupName}}). Names
* resolve through the preset inventory (PresetBook::find(), a name several presets share is an error), group keys
* through GroupKeys, every ID must pass the ValueCleaner ($validId). Nothing is stored for a key with an error; the
* caller (Composer, AssignRewrite) refuses the whole write when "errors" is not empty. The inventory is read lazily
* and at most once. Pure PHP; Divi and WordPress enter through the closures.
*/
final class PresetAssignment
{
private const MAX_STACK = 10;
private ?PresetBook $loaded = null;
/** @var array<string, array<string, mixed>>|null Preset ID => stored item. */
private ?array $index = null;
private ?PresetOverrides $overrides = null;
/**
* @param Closure(): PresetBook $book The inventory (DesignSystemReader), called lazily.
* @param Closure(string): array<string, string> $groupsOf DesignSystemWriter::groupsOf(): groupId => name.
* @param Closure(string): bool $validId True when the ID passes the ValueCleaner unchanged.
*/
public function __construct(private Closure $book, private Closure $groupsOf, private Closure $validId)
{
}
/**
* Applies "preset" and "group_presets" of $spec (only the keys present) to the attributes of a $module block.
* null (or "" for "preset") removes the assignment; a string or a list of 1–10 strings sets it.
*
* @param array<string, mixed> $attrs
* @param array<array-key, mixed> $spec
* @return array{attrs: array<string, mixed>, errors: array<string, string>} Error paths: "$path.preset",
* "$path.preset.<i>" (one entry of a list), "$path.group_presets" and "$path.group_presets.<key>".
*/
public function apply(string $module, array $attrs, array $spec, string $path): array
{
$errors = [];
if (array_key_exists('preset', $spec)) {
$attrs = $this->stack($module, $attrs, $spec['preset'], $path . '.preset', $errors);
}
if (array_key_exists('group_presets', $spec)) {
$attrs = $this->groups($module, $attrs, $spec['group_presets'], $path . '.group_presets', $errors);
}
return ['attrs' => $attrs, 'errors' => $errors];
}
/**
* The attributes without the local values that lie on values of the presets the block names (ruling R4).
*
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
public function clearOverrides(array $attrs): array
{
return $this->overrides()->clear($attrs);
}
/** The same instance every time, so the Warnings and clear_overrides read one inventory. */
public function overrides(): PresetOverrides
{
$this->overrides ??= new PresetOverrides(fn (string $id): ?array => $this->items()[$id] ?? null);
return $this->overrides;
}
/** @return array<string, string> Preset ID => name, module and group presets. */
public function names(): array
{
$names = [];
foreach ($this->items() as $id => $item) {
if (is_string($item['name'] ?? null)) {
$names[$id] = $item['name'];
}
}
return $names;
}
/**
* @param array<string, mixed> $attrs
* @param array<string, string> $errors
* @return array<string, mixed>
*/
private function stack(string $module, array $attrs, mixed $preset, string $path, array &$errors): array
{
if ($preset === null || $preset === '') {
unset($attrs['modulePreset']);
return $attrs;
}
$ids = $this->resolve(
new PresetScope(PresetScope::MODULE, $module),
$preset,
$path,
true,
'leere Liste – null entfernt das Preset',
$errors
);
if ($ids !== null) {
$attrs['modulePreset'] = $ids;
}
return $attrs;
}
/**
* @param array<string, mixed> $attrs
* @param array<string, string> $errors
* @return array<string, mixed>
*/
private function groups(string $module, array $attrs, mixed $wanted, string $path, array &$errors): array
{
if (! is_array($wanted) || ($wanted !== [] && array_is_list($wanted))) {
$errors[$path] = 'Objekt Gruppe → Preset erwartet';
return $attrs;
}
$groups = ($this->groupsOf)($module);
$entries = is_array($attrs['groupPreset'] ?? null) ? $attrs['groupPreset'] : [];
foreach ($wanted as $key => $value) {
$keyPath = $path . '.' . $key;
try {
$groupId = GroupKeys::resolve($groups, (string) $key);
} catch (InvalidArgumentException $e) {
$errors[$keyPath] = $e->getCode() === GroupKeys::UNKNOWN
? sprintf('%s in %s; vorhanden: %s', $e->getMessage(), $module, self::shortNames($groups))
: $e->getMessage();
continue;
}
if ($value === null) {
unset($entries[$groupId]);
continue;
}
$name = $groups[$groupId];
$ids = $this->resolve(
new PresetScope(PresetScope::GROUP, $name),
$value,
$keyPath,
false,
'leere Liste – null entfernt das Gruppen-Preset',
$errors
);
if ($ids !== null) {
$entries[$groupId] = ['presetId' => $ids, 'groupName' => $name];
}
}
if ($entries === []) {
unset($attrs['groupPreset']);
} else {
$attrs['groupPreset'] = $entries;
}
return $attrs;
}
/**
* A name, an ID or a list of 1–10 of them to the IDs in the same order; null when anything is wrong (the reasons
* are in $errors: one per list entry at "$path.<i>" when $perEntry, else the first one at $path).
*
* @param array<string, string> $errors
* @return list<string>|null
*/
private function resolve(
PresetScope $scope,
mixed $value,
string $path,
bool $perEntry,
string $emptyMessage,
array &$errors
): ?array {
$single = is_string($value);
if (! $single && ! (is_array($value) && array_is_list($value))) {
$errors[$path] = 'Text, Liste von Texten oder null erwartet';
return null;
}
$entries = $single ? [$value] : $value;
if ($entries === [] || count($entries) > self::MAX_STACK) {
$errors[$path] = $entries === [] ? $emptyMessage : 'höchstens ' . self::MAX_STACK . ' Presets im Stapel';
return null;
}
$ids = [];
$failed = false;
foreach ($entries as $position => $entry) {
try {
$ids[] = $this->idOf($scope, $entry, $ids);
} catch (InvalidArgumentException $e) {
$errors[$single || ! $perEntry ? $path : $path . '.' . $position] ??= $e->getMessage();
$failed = true;
}
}
return $failed ? null : $ids;
}
/**
* @param list<string> $taken IDs of the entries before this one.
* @throws InvalidArgumentException The German reason the entry cannot be used.
*/
private function idOf(PresetScope $scope, mixed $entry, array $taken): string
{
if (! is_string($entry)) {
throw new InvalidArgumentException('Preset-Name oder -ID als Text erwartet');
}
$found = $this->book()->find($scope, $entry);
if ($found === null) {
throw new InvalidArgumentException($scope->kind === PresetScope::MODULE
? sprintf('unbekanntes Preset; vorhanden für %s: %s', $scope->name, $this->namesIn($scope))
: sprintf('unbekanntes Gruppen-Preset "%s" (%s)', $entry, $scope->name));
}
$id = (string) $found['id'];
if (! ($this->validId)($id)) {
throw new InvalidArgumentException(sprintf('Preset-ID "%s" ist ungültig', $id));
}
if (in_array($id, $taken, true)) {
throw new InvalidArgumentException(sprintf('Preset "%s" steht mehrfach im Stapel', $id));
}
return $id;
}
private function namesIn(PresetScope $scope): string
{
$names = array_column($this->book()->items($scope), 'name');
return $names === [] ? '(keine)' : implode(', ', $names);
}
/** @param array<string, string> $groups groupId => groupName */
private static function shortNames(array $groups): string
{
$names = array_unique(array_map(
static fn (string $name): string => str_starts_with($name, 'divi/') ? substr($name, 5) : $name,
array_values($groups)
));
return $names === [] ? '(keine)' : implode(', ', $names);
}
private function book(): PresetBook
{
$this->loaded ??= ($this->book)();
return $this->loaded;
}
/** @return array<string, array<string, mixed>> */
private function items(): array
{
if ($this->index === null) {
$this->index = [];
foreach ($this->book()->data() as $records) {
foreach ($records as $record) {
foreach ($record['items'] as $id => $item) {
$this->index[(string) $id] = $item;
}
}
}
}
return $this->index;
}
}
@@ -199,9 +199,12 @@ final class PresetComposer
$groups = ($this->groupsOf)($module); $groups = ($this->groupsOf)($module);
foreach ($wanted as $group => $preset) { foreach ($wanted as $group => $preset) {
$path = $field . '.group_presets.' . $group; $path = $field . '.group_presets.' . $group;
$groupId = self::findGroup($groups, (string) $group); try {
if ($groupId === null) { $groupId = GroupKeys::resolve($groups, (string) $group);
$errors[$path] = sprintf('keine Gruppe "%s" in %s', $group, $module); } catch (InvalidArgumentException $e) {
$errors[$path] = $e->getCode() === GroupKeys::UNKNOWN
? sprintf('keine Gruppe "%s" in %s', $group, $module)
: $e->getMessage();
continue; continue;
} }
if ($preset === null) { if ($preset === null) {
@@ -226,18 +229,6 @@ final class PresetComposer
return $nested; return $nested;
} }
/** @param array<string, string> $groups groupId => groupName */
private static function findGroup(array $groups, string $wanted): ?string
{
foreach ($groups as $groupId => $name) {
if ($wanted === $groupId || $wanted === $name || 'divi/' . $wanted === $name) {
return (string) $groupId;
}
}
return null;
}
/** /**
* The name the ability resolved and cleaned ("name", also for a rename), else the existing preset's name. * The name the ability resolved and cleaned ("name", also for a rename), else the existing preset's name.
* *
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\GroupKeys;
use PHPUnit\Framework\TestCase;
final class GroupKeysTest extends TestCase
{
/** @return array<string, string> groupId => groupName, as DesignSystemWriter::groupsOf() returns it. */
private static function groups(): array
{
return [
'title.decoration.font' => 'divi/font',
'content.decoration.bodyFont' => 'divi/font-body',
'module.decoration.spacing' => 'divi/spacing',
'button.decoration.spacing' => 'divi/spacing',
];
}
public function testShortNameFullNameAndPathFindTheGroup(): void
{
self::assertSame('content.decoration.bodyFont', GroupKeys::resolve(self::groups(), 'font-body'));
self::assertSame('content.decoration.bodyFont', GroupKeys::resolve(self::groups(), 'divi/font-body'));
self::assertSame('content.decoration.bodyFont', GroupKeys::resolve(self::groups(), 'content.decoration.bodyFont'));
self::assertSame('title.decoration.font', GroupKeys::resolve(self::groups(), 'font'));
}
public function testSeveralGroupsWithTheSameNameNeedThePath(): void
{
self::assertSame('button.decoration.spacing', GroupKeys::resolve(self::groups(), 'button.decoration.spacing'));
foreach (['spacing', 'divi/spacing'] as $key) {
try {
GroupKeys::resolve(self::groups(), $key);
self::fail('Ambiguous group name must throw.');
} catch (InvalidArgumentException $e) {
self::assertSame(GroupKeys::AMBIGUOUS, $e->getCode());
self::assertSame(
sprintf(
'Gruppe "%s" ist mehrdeutig; bitte den Attributpfad angeben: '
. 'module.decoration.spacing, button.decoration.spacing.',
$key
),
$e->getMessage()
);
}
}
}
public function testUnknownGroup(): void
{
try {
GroupKeys::resolve(self::groups(), 'sizing');
self::fail('Unknown group must throw.');
} catch (InvalidArgumentException $e) {
self::assertSame(GroupKeys::UNKNOWN, $e->getCode());
self::assertSame('keine Gruppe "sizing"', $e->getMessage());
}
$this->expectException(InvalidArgumentException::class);
GroupKeys::resolve([], 'font');
}
}
@@ -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));
}
}
@@ -34,7 +34,7 @@ final class PresetComposerTest extends TestCase
}; };
} }
private static function composer(bool $unfiltered = true): PresetComposer private static function composer(bool $unfiltered = true, ?\Closure $groupsOf = null): PresetComposer
{ {
return new PresetComposer( return new PresetComposer(
ComposeFixture::composer($unfiltered), ComposeFixture::composer($unfiltered),
@@ -44,7 +44,7 @@ final class PresetComposerTest extends TestCase
ComposeFixture::sanitizer($unfiltered) ComposeFixture::sanitizer($unfiltered)
), ),
self::presetAttrs(), self::presetAttrs(),
static fn (string $module): array => [ $groupsOf ?? static fn (string $module): array => [
'module.decoration.spacing' => 'divi/spacing', 'module.decoration.spacing' => 'divi/spacing',
'button.decoration.font' => 'divi/font', 'button.decoration.font' => 'divi/font',
], ],
@@ -204,6 +204,39 @@ final class PresetComposerTest extends TestCase
self::assertStringContainsString('unbekanntes Gruppen-Preset', $result['errors']['items[0].group_presets.font']); self::assertStringContainsString('unbekanntes Gruppen-Preset', $result['errors']['items[0].group_presets.font']);
} }
/** Ruling R3: GroupKeys replaces the first-match search – a name two groups share needs the path. */
public function testAmbiguousGroupNameIsAnErrorAndThePathResolvesIt(): void
{
$groupsOf = static fn (string $module): array => [
'button.decoration.font' => 'divi/font',
'title.decoration.font' => 'divi/font',
];
$book = self::book()->with(new PresetScope('group', 'divi/font'), [
'type' => 'group', 'id' => 'ffffffffff', 'name' => 'Schrift', 'groupName' => 'divi/font',
'groupId' => 'title.decoration.font', 'moduleName' => 'divi/button', 'attrs' => [],
]);
$composer = self::composer(true, $groupsOf);
$scope = new PresetScope('module', 'divi/button');
$ambiguous = $composer->compose($book, $scope, null, [
'name' => 'X', 'group_presets' => ['font' => 'Schrift'],
], 'items[0]', null);
self::assertSame(
'Gruppe "font" ist mehrdeutig; bitte den Attributpfad angeben: '
. 'button.decoration.font, title.decoration.font.',
$ambiguous['errors']['items[0].group_presets.font']
);
$byPath = $composer->compose($book, $scope, null, [
'name' => 'X', 'group_presets' => ['title.decoration.font' => 'Schrift'],
], 'items[0]', null);
self::assertSame([], $byPath['errors']);
self::assertSame(
['title.decoration.font' => ['presetId' => ['ffffffffff'], 'groupName' => 'divi/font']],
$byPath['item']['groupPresets']
);
}
public function testNewPresetNeedsAName(): void public function testNewPresetNeedsAName(): void
{ {
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [], $result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [],