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);
|
$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, [],
|
||||||
|
|||||||
Reference in New Issue
Block a user