From fafa140546b5fd8763f7c2c4f2dd8ab7f72e692c Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 18 Oct 2026 16:45:29 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20GroupKeys=20und=20PresetAssignment=20?= =?UTF-8?q?=E2=80=93=20Preset-Stapel=20und=20Gruppen-Presets=20=C3=BCber?= =?UTF-8?q?=20eine=20Engstelle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/Integrations/Divi/Design/GroupKeys.php | 58 +++ .../Divi/Design/PresetAssignment.php | 289 +++++++++++++++ .../Divi/Design/PresetComposer.php | 21 +- .../Divi/Design/GroupKeysTest.php | 67 ++++ .../Divi/Design/PresetAssignmentTest.php | 341 ++++++++++++++++++ .../Divi/Design/PresetComposerTest.php | 37 +- 6 files changed, 796 insertions(+), 17 deletions(-) create mode 100644 src/Integrations/Divi/Design/GroupKeys.php create mode 100644 src/Integrations/Divi/Design/PresetAssignment.php create mode 100644 tests/Unit/Integrations/Divi/Design/GroupKeysTest.php create mode 100644 tests/Unit/Integrations/Divi/Design/PresetAssignmentTest.php diff --git a/src/Integrations/Divi/Design/GroupKeys.php b/src/Integrations/Divi/Design/GroupKeys.php new file mode 100644 index 0000000..072c89e --- /dev/null +++ b/src/Integrations/Divi/Design/GroupKeys.php @@ -0,0 +1,58 @@ + $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]; + } +} diff --git a/src/Integrations/Divi/Design/PresetAssignment.php b/src/Integrations/Divi/Design/PresetAssignment.php new file mode 100644 index 0000000..552d808 --- /dev/null +++ b/src/Integrations/Divi/Design/PresetAssignment.php @@ -0,0 +1,289 @@ +>|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 $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 $attrs + * @param array $spec + * @return array{attrs: array, errors: array} Error paths: "$path.preset", + * "$path.preset." (one entry of a list), "$path.group_presets" and "$path.group_presets.". + */ + 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 $attrs + * @return array + */ + 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 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 $attrs + * @param array $errors + * @return array + */ + 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 $attrs + * @param array $errors + * @return array + */ + 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." when $perEntry, else the first one at $path). + * + * @param array $errors + * @return list|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 $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 $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> */ + 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; + } +} diff --git a/src/Integrations/Divi/Design/PresetComposer.php b/src/Integrations/Divi/Design/PresetComposer.php index 8e10db0..891d3d5 100644 --- a/src/Integrations/Divi/Design/PresetComposer.php +++ b/src/Integrations/Divi/Design/PresetComposer.php @@ -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 $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. * diff --git a/tests/Unit/Integrations/Divi/Design/GroupKeysTest.php b/tests/Unit/Integrations/Divi/Design/GroupKeysTest.php new file mode 100644 index 0000000..06632ba --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/GroupKeysTest.php @@ -0,0 +1,67 @@ + 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'); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/PresetAssignmentTest.php b/tests/Unit/Integrations/Divi/Design/PresetAssignmentTest.php new file mode 100644 index 0000000..1a964fa --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/PresetAssignmentTest.php @@ -0,0 +1,341 @@ + [ + '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)); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/PresetComposerTest.php b/tests/Unit/Integrations/Divi/Design/PresetComposerTest.php index c85c3de..5e58a28 100644 --- a/tests/Unit/Integrations/Divi/Design/PresetComposerTest.php +++ b/tests/Unit/Integrations/Divi/Design/PresetComposerTest.php @@ -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, [],