From 337b87b572fdcaf7201a0dfe1cae18281053984f Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 14:07:23 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lit/divi-colors-set=20legt=20globale=20?= =?UTF-8?q?Farben=20an,=20=C3=A4ndert=20und=20deaktiviert=20sie=20(Probela?= =?UTF-8?q?uf,=20design=5Frev,=20Snapshot)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../Divi/Design/DiviColorsSetAbility.php | 342 ++++++++++++++++++ .../Divi/Design/DiviColorsSetAbilityTest.php | 303 ++++++++++++++++ 2 files changed, 645 insertions(+) create mode 100644 src/Integrations/Divi/Design/DiviColorsSetAbility.php create mode 100644 tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php diff --git a/src/Integrations/Divi/Design/DiviColorsSetAbility.php b/src/Integrations/Divi/Design/DiviColorsSetAbility.php new file mode 100644 index 0000000..80656b7 --- /dev/null +++ b/src/Integrations/Divi/Design/DiviColorsSetAbility.php @@ -0,0 +1,342 @@ + '', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active', + 'usedInPosts' => []]; + + /** @param (Closure(int): string)|null $random Random characters for new IDs (ColorBook::newId(); tests). */ + public function __construct( + private DesignSystemWriter $writer, + private GlobalColorsAdapter $colors, + private DesignChange $change, + private GlobalUsages $usages, + private ?Closure $random = null + ) { + } + + public function name(): string + { + return 'lit/divi-colors-set'; + } + + public function label(): string + { + return 'Globale Divi-Farben anlegen und ändern'; + } + + public function description(): string + { + return 'Legt globale Farben an oder ändert sie. items: id nennt eine vorhandene Farbe (ID, Name, Alias ' + . 'primary, secondary, heading, body, link oder Optionsname wie accent_color); ohne id entsteht eine neue ' + . 'Farbe aus ' + . 'label und color. color: Hex, rgb()/rgba()/hsl()/hsla(), Farbname oder {{color:}} mit relative ' + . '(hue, saturation, lightness, opacity). status inactive deaktiviert (Farben werden nie gelöscht); die ' + . 'fünf Hauptfarben ändern nur color. Wirkt auf alle Seiten: zuerst Probelauf (diff, betroffene Seiten, ' + . 'Warnungen, design_rev), nach Rückfrage beim Nutzer confirm: true mit design_rev. Vorher wird ein ' + . 'Snapshot angelegt (lit/divi-design-restore).'; + } + + public function inputSchema(): array + { + $number = ['type' => 'number']; + + return [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'minItems' => 1, + 'maxItems' => self::MAX_ITEMS, + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Vorhandene Farbe: ID, Name, Alias oder Optionsname.', + ], + 'label' => ['type' => 'string', 'description' => 'Name einer neuen Farbe oder umbenennen.'], + 'color' => ['type' => 'string'], + 'relative' => [ + 'type' => 'object', + 'properties' => [ + 'hue' => $number, + 'saturation' => $number, + 'lightness' => $number, + 'opacity' => $number, + ], + ], + 'folder' => ['type' => 'string'], + 'status' => ['type' => 'string', 'description' => 'active oder inactive.'], + ], + ], + ], + ] + DesignChange::inputProperties(), + 'required' => ['items'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return DesignChange::outputSchema(); + } + + public function annotations(): array + { + return Annotations::destructive(false); + } + + public function isDirect(): bool + { + return false; + } + + /** Reads and writes the global colors; the warnings read the presets (DesignRead). */ + public function diviFeatures(array $input): array + { + return [DiviFeature::DesignRead, DiviFeature::ColorWrite]; + } + + public function permission(array $input): bool|WP_Error + { + return DesignRights::colors($this->writer) ?? true; + } + + public function execute(array $input): mixed + { + $refusal = DesignRights::colors($this->writer); + if ($refusal !== null) { + return $refusal; + } + $items = $input['items'] ?? null; + if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) { + return Errors::invalidInput( + 'items fehlt oder ist zu lang; nichts wurde gespeichert.', + ['items' => sprintf('1 bis %d Einträge', self::MAX_ITEMS)] + ); + } + + return $this->change->run( + $this->name(), + new ColorArea($this->colors), + $input, + fn (array $raw): DesignPlan|WP_Error => $this->plan(ColorBook::fromDivi($raw), $items) + ); + } + + /** @param list $items */ + private function plan(ColorBook $before, array $items): DesignPlan|WP_Error + { + $book = $before; + $errors = []; + foreach ($items as $index => $item) { + $field = sprintf('items[%d]', $index); + if (! is_array($item)) { + $errors[$field] = 'Objekt erwartet'; + continue; + } + $book = $this->apply($book, $item, $field, $errors); + } + if ($errors !== []) { + return Errors::invalidInput('Ungültige Farb-Angaben; nichts wurde gespeichert.', $errors); + } + + $diff = ColorDiff::between($before, $book); + $touched = []; + foreach ($diff as $row) { + if ($row['action'] !== 'added') { + $touched[] = (string) $row['id']; + } + } + $touched = array_values(array_unique($touched)); + + return new DesignPlan( + $book->data(), + $diff, + ColorDiff::summary($diff), + $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched)) + ); + } + + /** + * $book with the color of $item created or changed; problems go to $errors and leave $book as it was. + * + * @param array $item + * @param array $errors + */ + private function apply(ColorBook $book, array $item, string $field, array &$errors): ColorBook + { + $known = count($errors); + $id = array_key_exists('id', $item) ? self::existing($book, $item['id'], $field . '.id', $errors) : null; + if (count($errors) > $known) { + return $book; + } + + $main = $id !== null && $book->isCustomizer($id); + $entry = $id === null ? self::NEW_ENTRY : (array) $book->entry($id); + foreach (['label', 'folder', 'status', 'color'] as $key) { + if (! array_key_exists($key, $item)) { + if ($id === null && ($key === 'label' || $key === 'color')) { + $errors[$field . '.' . $key] = 'Pflicht für eine neue Farbe'; + } + continue; + } + if ($main && $key !== 'color') { + $errors[$field . '.' . $key] = self::MAIN_COLOR_RULE; + continue; + } + try { + $entry[$key] = match ($key) { + 'label' => self::cleanLabel($book, $item['label'], $id), + 'folder' => self::folder($item['folder']), + 'status' => self::status($item['status']), + default => self::value($item), + }; + } catch (InvalidArgumentException $e) { + $errors[$field . '.' . $key] = $e->getMessage(); + } + } + if (count($errors) > $known || ($id !== null && $entry === $book->entry($id))) { + return $book; + } + + if (! $main) { + // Ruling V3: the main colors' lastUpdated stays "" (ColorBook ignores it). + $entry['lastUpdated'] = ColorBook::now(); + } + + return $book->with($id ?? $this->newId($book), $entry); + } + + /** @param array $errors */ + private static function existing(ColorBook $book, mixed $needle, string $field, array &$errors): ?string + { + if (! is_string($needle) || trim($needle) === '') { + $errors[$field] = 'ID, Name, Alias oder Optionsname der Farbe erwartet'; + + return null; + } + try { + $id = $book->find($needle); + } catch (InvalidArgumentException $e) { + $errors[$field] = $e->getMessage(); + + return null; + } + if ($id === null) { + $errors[$field] = sprintf('unbekannte Farbe „%s“', $needle); + } + + return $id; + } + + /** + * A label no other color has as name, ID or alias (it would make {{color:…}} ambiguous). + * + * @throws InvalidArgumentException + */ + private static function cleanLabel(ColorBook $book, mixed $label, ?string $id): string + { + $clean = is_string($label) && strlen($label) <= self::LABEL_MAX * 4 ? sanitize_text_field($label) : ''; + if ($clean === '' || mb_strlen($clean) > self::LABEL_MAX) { + throw new InvalidArgumentException(self::LABEL_RULE); + } + try { + $taken = $book->find($clean); + } catch (InvalidArgumentException) { + $taken = ''; // several colors share the name already + } + if ($taken !== null && $taken !== $id) { + throw new InvalidArgumentException(sprintf( + 'Name schon vergeben (%s); zum Ändern id angeben oder einen anderen Namen wählen', + $taken === '' ? 'mehrfach' : $taken + )); + } + + return $clean; + } + + /** @throws InvalidArgumentException */ + private static function folder(mixed $folder): string + { + $clean = is_string($folder) && strlen($folder) <= self::LABEL_MAX * 4 ? sanitize_text_field($folder) : null; + if ($clean === null || mb_strlen($clean) > self::LABEL_MAX || $clean === 'customizer') { + throw new InvalidArgumentException('Ordner: bis 100 Zeichen ohne HTML, nicht „customizer“'); + } + + return $clean; + } + + /** @throws InvalidArgumentException */ + private static function status(mixed $status): string + { + if (! is_string($status) || ! in_array($status, self::STATUSES, true)) { + throw new InvalidArgumentException('active oder inactive'); + } + + return $status; + } + + /** + * @param array $item + * @throws InvalidArgumentException + */ + private static function value(array $item): string + { + $color = $item['color']; + if (! is_string($color)) { + throw new InvalidArgumentException('Farbwert als Text erwartet'); + } + $color = trim($color); + $problem = ColorValue::check($color); + if ($problem !== null) { + throw new InvalidArgumentException($problem); + } + + return $color; + } + + private function newId(ColorBook $book): string + { + do { + $id = ColorBook::newId($this->random); + } while ($book->entry($id) !== null); + + return $id; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php new file mode 100644 index 0000000..bc19601 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php @@ -0,0 +1,303 @@ +> What the adapter stores (ColorAreaTest::adapter()). */ + private array $stored = []; + + /** @var list, 1: bool}> */ + private array $saves = []; + + protected function setUp(): void + { + parent::setUp(); + // DesignLock (final review I-5): the lock is always free here. + Functions\when('add_option')->justReturn(true); + Functions\when('delete_option')->justReturn(true); + Functions\when('current_user_can')->justReturn(true); + $this->stored = ColorAreaTest::stored(); + $this->saves = []; + } + + /** @param array $presets DesignSystemWriter::presets() ([] = PresetBookTest::stored()). */ + private function ability( + ?GlobalUsages $usages = null, + ?SnapshotStore $snapshots = null, + array $presets = [], + ?GlobalColorsAdapter $colors = null, + bool $visualBuilder = true + ): DiviColorsSetAbility { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn($visualBuilder); + $writer->allows('presets')->andReturn($presets === [] ? PresetBookTest::stored() : $presets); + $counter = 0; + + return new DiviColorsSetAbility( + $writer, + $colors ?? ColorAreaTest::adapter($this->stored, $this->saves), + new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)), + $usages ?? self::usages(self::unused()), + static function (int $length) use (&$counter): string { + return str_pad((string) ++$counter, $length, '0', STR_PAD_LEFT); + } + ); + } + + private static function usages(UsageReport $report): GlobalUsages + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->allows('reportFor')->andReturn($report); + + return $usages; + } + + private static function unused(): UsageReport + { + return new UsageReport(0, [], 0, 0, [], 0, false); + } + + private static function used(): UsageReport + { + return new UsageReport(1, [['post_id' => 7, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']], + 0, 1, [], 0, false); + } + + /** A SnapshotStore that expects one snapshot of the stored colors and the log ID 3 attached to it. */ + private static function snapshots(): SnapshotStore + { + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save') + ->with('colors', ColorBook::fromDivi(ColorAreaTest::stored())->data(), 'lit/divi-colors-set') + ->andReturn('colors-1'); + $snapshots->expects('attachLog')->with('colors', 'colors-1', 3); + + return $snapshots; + } + + public function testSchemaAndMeta(): void + { + $ability = $this->ability(); + $properties = $ability->inputSchema()['properties']; + + self::assertSame('lit/divi-colors-set', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertSame(Annotations::destructive(false), $ability->annotations()); + self::assertSame([DiviFeature::DesignRead, DiviFeature::ColorWrite], $ability->diviFeatures([])); + self::assertSame(DiviColorsSetAbility::MAX_ITEMS, $properties['items']['maxItems']); + self::assertSame(50, DiviColorsSetAbility::MAX_ITEMS); + self::assertArrayHasKey('design_rev', $properties); + self::assertArrayNotHasKey('delete', $properties['items']['items']['properties'], 'colors are deactivated'); + self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2)); + self::assertStringContainsString('confirm: true mit design_rev', $ability->description()); + self::assertStringContainsString('lit/divi-design-restore', $ability->description()); + } + + public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void + { + $input = ['items' => [['label' => 'Weiß', 'color' => '#ffffff']]]; + + self::assertToolError($this->ability(visualBuilder: false)->execute($input), 'forbidden'); + self::assertToolError($this->ability(visualBuilder: false)->permission($input), 'forbidden'); + + Functions\when('current_user_can')->justReturn(false); + self::assertToolError($this->ability()->execute($input), 'forbidden'); + self::assertToolError($this->ability()->permission($input), 'forbidden'); + self::assertSame([], $this->saves); + } + + public function testItemsMustBeAListOfOneToFifty(): void + { + $tooMany = array_fill(0, 51, ['label' => 'X', 'color' => '#fff']); + foreach ([[], ['items' => []], ['items' => ['a' => []]], ['items' => $tooMany]] as $input) { + self::assertToolError($this->ability()->execute($input), 'validation_failed'); + } + } + + public function testDryRunThenConfirmCreatesAColor(): void + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor')->never(); // ruling V8: a new color has no usages + $ability = $this->ability($usages, self::snapshots()); + $input = ['items' => [['label' => 'Weiß', 'color' => '#ffffff', 'folder' => 'Basis']]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + self::assertSame(['added'], array_column($dry['diff'], 'action')); + self::assertSame('Weiß', $dry['diff'][0]['name']); + self::assertSame(ColorBook::fromDivi(ColorAreaTest::stored())->rev(), $dry['design_rev']); + self::assertSame([], $dry['affected']); + self::assertSame(0, $dry['affected_count']); + self::assertSame([], $dry['warnings']); + self::assertStringContainsString(ColorDiff::summary($dry['diff']), $dry['message']); + self::assertSame([], $this->saves, 'the dry run saves nothing'); + + $result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + self::assertSame('colors-1', $result['snapshot_id']); + $created = array_diff_key($this->stored, ColorAreaTest::stored()); + self::assertCount(1, $created); + self::assertMatchesRegularExpression('/^gcid-[0-9a-z]{10}$/D', (string) array_key_first($created)); + $entry = reset($created); + self::assertSame( + ['#ffffff', 'Basis', 'Weiß', 'active', []], + [$entry['color'], $entry['folder'], $entry['label'], $entry['status'], $entry['usedInPosts']] + ); + self::assertMatchesRegularExpression('/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/D', $entry['lastUpdated']); + self::assertFalse($this->saves[0][1], 'merged'); + self::assertSame(ColorBook::fromDivi($this->stored)->rev(), $result['design_rev']); + } + + public function testConfirmNeedsTheDesignRevOfTheDryRun(): void + { + $input = ['items' => [['id' => 'Holz dunkel', 'color' => '#000000']], 'confirm' => true]; + + self::assertToolError($this->ability()->execute($input), 'validation_failed'); + self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch'); + self::assertSame([], $this->saves); + } + + public function testChangingAColorWritesOnlyThatColorAndListsItsUsages(): void + { + $usages = Mockery::mock(GlobalUsages::class); + $usages->expects('reportFor') + ->twice() // dry run and confirm + ->with(Mockery::on(static fn (ReferenceUsageQuery $query): bool + => $query->matches('') + && ! $query->matches(''))) + ->andReturn(self::used()); + $ability = $this->ability($usages, self::snapshots()); + $input = ['items' => [['id' => 'holz DUNKEL', 'color' => '#000000', 'label' => 'Holz schwarz']]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + self::assertSame(['changed'], array_column($dry['diff'], 'action')); + self::assertSame('gcid-holz000001', $dry['diff'][0]['id']); + self::assertSame(7, $dry['affected'][0]['post_id']); + self::assertSame(1, $dry['affected_count']); + + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + self::assertSame(['gcid-holz000001'], array_keys($this->saves[0][0])); + self::assertSame( + ['#000000', 'Holz schwarz'], + [$this->stored['gcid-holz000001']['color'], $this->stored['gcid-holz000001']['label']] + ); + } + + public function testDeactivatingIsHowAColorIsRemoved(): void + { + $dry = self::assertToolError( + $this->ability()->execute(['items' => [['id' => 'gcid-holz000001', 'status' => 'inactive']]]), + 'confirm_required' + ); + + self::assertSame(['deactivated'], array_column($dry['diff'], 'action')); + } + + public function testAnItemThatChangesNothingIsNoChange(): void + { + $result = $this->ability()->execute(['items' => [['id' => 'Holz dunkel', 'color' => '#5b3a29']]]); + + self::assertSame([], $result['diff']); + self::assertStringStartsWith('Keine Änderung', $result['summary']); + self::assertSame([], $this->saves); + } + + /** Ruling V3: a main color is found by its option name and only its value is written. */ + public function testMainColorsChangeOnlyTheirValue(): void + { + $ability = $this->ability(null, self::snapshots()); + $input = ['items' => [['id' => 'accent_color', 'color' => '#123456']]]; + + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + self::assertSame('gcid-primary-color', $dry['diff'][0]['id']); + + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + + self::assertSame(['gcid-primary-color'], array_keys($this->saves[0][0])); + self::assertSame('#123456', $this->stored['gcid-primary-color']['color']); + } + + public function testMainColorsCannotBeRenamedMovedOrDeactivated(): void + { + $error = $this->ability()->execute(['items' => [ + ['id' => 'primary', 'label' => 'Haupt', 'folder' => 'Basis', 'status' => 'inactive'], + ]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame( + ['items[0].label', 'items[0].folder', 'items[0].status'], + array_keys(self::errorFields($error)) + ); + } + + public function testAllProblemsOfAllItemsAreCollected(): void + { + $error = $this->ability()->execute(['items' => [ + ['id' => 'Fehlt', 'color' => '#ffffff'], + ['label' => '', 'color' => 'kein Wert!'], + ['color' => '#ffffff'], + ['label' => 'Holz dunkel', 'color' => '#ffffff'], + ['id' => 'gcid-holz000001', 'folder' => ['x'], 'status' => 'gelöscht'], + 'kein Objekt', + ]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame( + ['items[0].id', 'items[1].label', 'items[1].color', 'items[2].label', 'items[3].label', 'items[4].folder', + 'items[4].status', 'items[5]'], + array_keys(self::errorFields($error)) + ); + self::assertSame([], $this->saves); + } + + public function testAFailedWriteIsRolledBack(): void + { + $calls = []; + $colors = Mockery::mock(GlobalColorsAdapter::class); + $colors->allows('read')->andReturn(ColorAreaTest::stored()); + $colors->allows('save')->andReturnUsing(static function (array $entries, bool $replace) use (&$calls): void { + $calls[] = $replace; + if (count($calls) === 1) { + throw new RuntimeException('Divi hat nichts gespeichert'); + } + }); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('colors-1'); + $ability = $this->ability(null, $snapshots, [], $colors); + $input = ['items' => [['id' => 'Holz dunkel', 'color' => '#000000']]]; + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + try { + $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); + self::fail('expected WriteFailed'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame('colors-1', $e->details['snapshot_id']); + } + self::assertSame([false, true], $calls, 'the merge failed, the snapshot replaced all colors'); + } +}