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:
co-authored by
Claude Sonnet 5.5
parent
6546e23e7f
commit
fafa140546
@@ -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);
|
||||
foreach ($wanted as $group => $preset) {
|
||||
$path = $field . '.group_presets.' . $group;
|
||||
$groupId = self::findGroup($groups, (string) $group);
|
||||
if ($groupId === null) {
|
||||
$errors[$path] = sprintf('keine Gruppe "%s" in %s', $group, $module);
|
||||
try {
|
||||
$groupId = GroupKeys::resolve($groups, (string) $group);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$errors[$path] = $e->getCode() === GroupKeys::UNKNOWN
|
||||
? sprintf('keine Gruppe "%s" in %s', $group, $module)
|
||||
: $e->getMessage();
|
||||
continue;
|
||||
}
|
||||
if ($preset === null) {
|
||||
@@ -226,18 +229,6 @@ final class PresetComposer
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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(
|
||||
ComposeFixture::composer($unfiltered),
|
||||
@@ -44,7 +44,7 @@ final class PresetComposerTest extends TestCase
|
||||
ComposeFixture::sanitizer($unfiltered)
|
||||
),
|
||||
self::presetAttrs(),
|
||||
static fn (string $module): array => [
|
||||
$groupsOf ?? static fn (string $module): array => [
|
||||
'module.decoration.spacing' => 'divi/spacing',
|
||||
'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']);
|
||||
}
|
||||
|
||||
/** 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
|
||||
{
|
||||
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [],
|
||||
|
||||
Reference in New Issue
Block a user