From 879ca752e7d085bd4696047953cd5970e0d0f94e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 17:51:50 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lit/divi-theme-options-update=20=C3=A4n?= =?UTF-8?q?dert=20Theme-Optionen=20=C3=BCber=20DesignChange=20(Pr=C3=BCfun?= =?UTF-8?q?g,=20Divi-Sanitizer,=20Schriftvorschl=C3=A4ge)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../Design/DiviThemeOptionsUpdateAbility.php | 246 +++++++++++++ src/Integrations/Divi/Design/FontCheck.php | 27 ++ .../DiviThemeOptionsUpdateAbilityTest.php | 327 ++++++++++++++++++ .../Divi/Design/FontCheckTest.php | 57 +++ 4 files changed, 657 insertions(+) create mode 100644 src/Integrations/Divi/Design/DiviThemeOptionsUpdateAbility.php create mode 100644 src/Integrations/Divi/Design/FontCheck.php create mode 100644 tests/Unit/Integrations/Divi/Design/DiviThemeOptionsUpdateAbilityTest.php create mode 100644 tests/Unit/Integrations/Divi/Design/FontCheckTest.php diff --git a/src/Integrations/Divi/Design/DiviThemeOptionsUpdateAbility.php b/src/Integrations/Divi/Design/DiviThemeOptionsUpdateAbility.php new file mode 100644 index 0000000..1e004f2 --- /dev/null +++ b/src/Integrations/Divi/Design/DiviThemeOptionsUpdateAbility.php @@ -0,0 +1,246 @@ +. Theme options have no page reference: the dry run warns about the whole website instead. + */ +final class DiviThemeOptionsUpdateAbility implements Ability, RequiresDiviFeatures +{ + private const RESET_HINT = 'zum Zurücksetzen den Wert null angeben'; + + private const BUILDER = 'Geänderte Schriften wirken auch im Divi-Builder (Standardschrift der Module).'; + + public function __construct( + private DesignSystemWriter $writer, + private ThemeOptionsAdapter $options, + private FontCatalog $fonts, + private DesignChange $change + ) { + } + + public function name(): string + { + return 'lit/divi-theme-options-update'; + } + + public function label(): string + { + return 'Divi-Theme-Optionen ändern'; + } + + public function description(): string + { + return 'Ändert Divis Theme-Optionen für Schriften, Schriftgrößen und Layout. set: {Schlüssel: Wert}; null ' + . 'setzt auf den Divi-Standard zurück. Schlüssel, aktuelle Werte und Bereiche liefert ' + . 'lit/divi-theme-options-get; Schriften mit dem exakten Namen einer Divi-Schrift (unbekannte Namen ' + . 'bekommen Vorschläge). Die Hauptfarben ändert lit/divi-colors-set. Wirkt auf die gesamte Website: zuerst ' + . 'Probelauf (diff, 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 + { + $keys = []; + foreach (ThemeOptionSpec::KEYS as $key) { + $keys[$key] = self::keySchema($key); + } + + return [ + 'type' => 'object', + 'properties' => [ + 'set' => [ + 'type' => 'object', + 'minProperties' => 1, + 'properties' => $keys, + 'additionalProperties' => false, + 'description' => 'Theme-Option => neuer Wert; null = Divi-Standard.', + ], + ] + DesignChange::inputProperties(), + 'required' => ['set'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return DesignChange::outputSchema(); + } + + public function annotations(): array + { + return Annotations::destructive(false); + } + + public function isDirect(): bool + { + return false; + } + + /** Reads et_divi and the Customizer table (ThemeOptionRead); sanitizer, font list and writing: ThemeOptionWrite. */ + public function diviFeatures(array $input): array + { + return [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite]; + } + + public function permission(array $input): bool|WP_Error + { + return DesignRights::themeOptions($this->writer, $this->options) ?? true; + } + + public function execute(array $input): mixed + { + $refusal = DesignRights::themeOptions($this->writer, $this->options); + if ($refusal !== null) { + return $refusal; + } + $set = $input['set'] ?? null; + if (! is_array($set) || $set === [] || array_is_list($set)) { + return Errors::invalidInput( + 'set fehlt oder ist leer; nichts wurde gespeichert.', + ['set' => 'Objekt {Theme-Option: Wert}, Schlüssel aus lit/divi-theme-options-get'] + ); + } + + return $this->change->run( + $this->name(), + new ThemeOptionsArea($this->options), + $input, + fn (array $raw): DesignPlan|WP_Error => $this->plan(ThemeOptionBook::fromDivi($raw), $set) + ); + } + + /** @param array $set */ + private function plan(ThemeOptionBook $before, array $set): DesignPlan|WP_Error + { + $errors = []; + ItemKeys::refuseUnknown($set, ThemeOptionSpec::KEYS, 'set', $errors, self::RESET_HINT); + $book = $before; + // In the fixed key order, so a tablet size is compared with the desktop size of this call. + foreach (ThemeOptionSpec::KEYS as $key) { + if (! array_key_exists($key, $set)) { + continue; + } + if ($set[$key] === null) { + $book = $book->without($key); + continue; + } + $value = $this->value($key, $set[$key], $errors); + if ($value !== null && ! self::inherited($book, $key, $value)) { + $book = $book->with($key, $value); + } + } + if ($errors !== []) { + return Errors::invalidInput('Ungültige Theme-Optionen; nichts wurde gespeichert.', $errors); + } + + $diff = ThemeOptionDiff::between($before, $book); + + return new DesignPlan($book->data(), $diff, ThemeOptionDiff::summary($diff), null, self::warnings($diff)); + } + + /** + * The value Divi stores for $key, or null after noting the problem in $errors. + * + * @param array $errors + */ + private function value(string $key, mixed $value, array &$errors): int|float|string|null + { + $field = 'set.' . $key; + if ($value === false || $value === 'none') { + $errors[$field] = 'für den Divi-Standard null angeben, nicht false oder "none"'; + + return null; + } + $problem = ThemeOptionSpec::check($key, $value); + if ($problem === null && ThemeOptionSpec::definition($key)['type'] === 'font') { + // Before Divi's sanitizer: it would only answer false, without suggestions. + $problem = FontCheck::problem($this->fonts, (string) $value); + } + if ($problem !== null) { + $errors[$field] = $problem; + + return null; + } + + $normalized = ThemeOptionSpec::normalize($key, $value); + $sanitized = $this->options->sanitize($key, $normalized); + if (! is_int($sanitized) && ! is_float($sanitized) && ! is_string($sanitized)) { + $errors[$field] = 'Divi lehnt den Wert ab'; + + return null; + } + if (! ThemeOptionsArea::same($sanitized, $normalized)) { + $errors[$field] = sprintf('Divi würde den Wert verändert speichern (%s)', $sanitized); + + return null; + } + + return $sanitized; + } + + /** Ruling W3: an unset tablet/phone size given the size it inherits stays unset – it keeps following that size. */ + private static function inherited(ThemeOptionBook $book, string $key, int|float|string $value): bool + { + return isset(ThemeOptionSpec::definition($key)['inherits']) && ! $book->has($key) + && ThemeOptionsArea::same(ThemeOptionSpec::effective($key, $book->data()), $value); + } + + /** + * Ruling W7: no affected pages, but the reach of the change. + * + * @param list> $diff + * @return list + */ + private static function warnings(array $diff): array + { + if ($diff === []) { + return []; + } + foreach ($diff as $row) { + if (ThemeOptionSpec::definition((string) $row['key'])['type'] === 'font') { + return [ThemeOptionsArea::SITE_WIDE, self::BUILDER]; + } + } + + return [ThemeOptionsArea::SITE_WIDE]; + } + + /** @return array The schema of one key: its types, the range of numbers, a description. */ + private static function keySchema(string $key): array + { + $definition = ThemeOptionSpec::definition($key); + $schema = ['type' => match ($definition['type']) { + 'int' => ['integer', 'string', 'null'], + 'float' => ['number', 'string', 'null'], + 'weight' => ['string', 'integer', 'null'], + default => ['string', 'null'], + }]; + if (isset($definition['min'], $definition['max'])) { + $schema['minimum'] = $definition['min']; + $schema['maximum'] = $definition['max']; + } + $schema['description'] = $definition['label'] . (isset($definition['choices']) + ? ': ' . implode(', ', $definition['choices']) + : ''); + + return $schema; + } +} diff --git a/src/Integrations/Divi/Design/FontCheck.php b/src/Integrations/Divi/Design/FontCheck.php new file mode 100644 index 0000000..8cfa972 --- /dev/null +++ b/src/Integrations/Divi/Design/FontCheck.php @@ -0,0 +1,27 @@ +has($name)) { + return null; + } + $similar = FontSuggest::similar($name, $fonts->names()); + + return sprintf('unbekannte Schrift „%s“', $name) . ($similar === [] + ? '; eigene Schriften lädt lit/divi-fonts-upload hoch' + : '; ähnlich: ' . implode(', ', $similar)); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DiviThemeOptionsUpdateAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviThemeOptionsUpdateAbilityTest.php new file mode 100644 index 0000000..f3ffbaf --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviThemeOptionsUpdateAbilityTest.php @@ -0,0 +1,327 @@ + What the adapter stores (ThemeOptionsAreaTest::adapter()). */ + private array $stored = []; + + /** @var list, 1: list}> */ + private array $writes = []; + + 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 = ThemeOptionsAreaTest::stored(); + $this->writes = []; + } + + /** @param (Closure(string, mixed): mixed)|null $sanitize Divi's sanitizer (default: keeps the value). */ + private function ability( + ?SnapshotStore $snapshots = null, + ?Closure $sanitize = null, + bool $customize = true, + ?ThemeOptionsAdapter $options = null + ): DiviThemeOptionsUpdateAbility { + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn(true); + + return new DiviThemeOptionsUpdateAbility( + $writer, + $options ?? ThemeOptionsAreaTest::adapter($this->stored, $this->writes, $sanitize, $customize), + FontCheckTest::catalog(), + new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)) + ); + } + + /** A SnapshotStore that expects one snapshot of the stored options and the log ID 3 attached to it. */ + private static function snapshots(): SnapshotStore + { + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save') + ->with( + 'theme_options', + ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->data(), + 'lit/divi-theme-options-update' + ) + ->andReturn('theme_options-1'); + $snapshots->expects('attachLog')->with('theme_options', 'theme_options-1', 3); + + return $snapshots; + } + + /** + * Dry run, then confirm with its design_rev. + * + * @param array $input + * @return array{0: array, 1: mixed} + */ + private function confirmed(DiviThemeOptionsUpdateAbility $ability, array $input): array + { + $dry = self::assertToolError($ability->execute($input), 'confirm_required'); + + return [$dry, $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']])]; + } + + public function testSchemaAndMeta(): void + { + $ability = $this->ability(); + $properties = $ability->inputSchema()['properties']; + + self::assertSame('lit/divi-theme-options-update', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertSame(Annotations::destructive(false), $ability->annotations()); + self::assertSame([DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $ability->diviFeatures([])); + self::assertSame(ThemeOptionSpec::KEYS, array_keys($properties['set']['properties'])); + self::assertFalse($properties['set']['additionalProperties']); + self::assertSame(10, $properties['set']['properties']['body_font_size']['minimum']); + self::assertSame(32, $properties['set']['properties']['body_font_size']['maximum']); + self::assertContains('null', $properties['set']['properties']['heading_font']['type']); + self::assertArrayHasKey('design_rev', $properties); + self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2)); + self::assertStringContainsString('confirm: true mit design_rev', $ability->description()); + self::assertStringContainsString('lit/divi-colors-set', $ability->description()); + } + + /** Spec §10, W14: edit_theme_options and Divi's role right "Theme-Anpassung", before anything is read. */ + public function testNeedsEditThemeOptionsAndTheCustomizerRight(): void + { + $input = ['set' => ['body_font_size' => 18]]; + + self::assertToolError($this->ability(customize: false)->execute($input), 'forbidden'); + self::assertToolError($this->ability(customize: 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->writes); + } + + public function testSetMustBeAnObjectWithAtLeastOneKey(): void + { + foreach ([[], ['set' => []], ['set' => ['body_font']], ['set' => 'body_font']] as $input) { + self::assertToolError($this->ability()->execute($input), 'validation_failed'); + } + } + + public function testDryRunThenConfirmWritesTheOptions(): void + { + $input = ['set' => ['heading_font' => 'Roboto', 'body_font_size' => '18']]; + + [$dry, $result] = $this->confirmed($this->ability(self::snapshots()), $input); + + self::assertSame( + [['heading_font', 'added'], ['body_font_size', 'changed']], + array_map(static fn (array $row): array => [$row['key'], $row['action']], $dry['diff']) + ); + self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->rev(), $dry['design_rev']); + self::assertStringContainsString(ThemeOptionDiff::summary($dry['diff']), $dry['message']); + self::assertSame([], $dry['affected'], 'theme options have no page reference (W7)'); + self::assertSame(ThemeOptionsArea::SITE_WIDE, $dry['warnings'][0]); + self::assertStringContainsString('Builder', $dry['warnings'][1]); + + self::assertSame('theme_options-1', $result['snapshot_id']); + self::assertSame(18, $this->stored['body_font_size'], 'stored as the number Divi keeps'); + self::assertSame('Roboto', $this->stored['heading_font']); + self::assertSame(ThemeOptionBook::fromDivi($this->stored)->rev(), $result['design_rev']); + } + + /** Only fonts reach the builder (W7). */ + public function testSizesOnlyWarnAboutTheWholeWebsite(): void + { + $dry = self::assertToolError( + $this->ability()->execute(['set' => ['content_width' => 1200]]), + 'confirm_required' + ); + + self::assertSame([ThemeOptionsArea::SITE_WIDE], $dry['warnings']); + } + + /** Ruling W3: null resets to Divi's default – the key is deleted, not stored as "none" or false. */ + public function testNullResetsToTheDiviDefault(): void + { + [$dry] = $this->confirmed($this->ability(self::snapshots()), ['set' => ['body_font_height' => null]]); + + self::assertSame(['reset'], array_column($dry['diff'], 'action')); + self::assertContains('body_font_height', $this->writes[0][1]); + self::assertArrayNotHasKey('body_font_height', $this->stored); + } + + public function testNoneAndFalseAreRefusedWithAHint(): void + { + $error = $this->ability()->execute(['set' => ['heading_font' => false, 'body_font' => 'none']]); + + self::assertToolError($error, 'validation_failed'); + $fields = self::errorFields($error); + self::assertSame(['set.heading_font', 'set.body_font'], array_keys($fields)); + self::assertStringContainsString('null', $fields['set.body_font']); + } + + public function testAllProblemsAreCollectedAndUnknownKeysRefused(): void + { + $error = $this->ability()->execute(['set' => [ + 'gutter_width' => 'breit', + 'accent_color' => '#ffffff', + 'delete' => true, + 'body_font_size' => 99, + 'body_header_style' => 'bold', + ]]); + + self::assertToolError($error, 'validation_failed'); + $fields = self::errorFields($error); + self::assertSame( + ['set.accent_color', 'set.delete', 'set.body_font_size', 'set.body_header_style', 'set.gutter_width'], + array_keys($fields) + ); + self::assertSame('unbekanntes Feld', $fields['set.accent_color']); + self::assertStringContainsString('null', $fields['set.delete']); + self::assertSame([], $this->writes); + } + + /** Ruling W5: the exact name of a Divi font; unknown names get suggestions before Divi's sanitizer is asked. */ + public function testUnknownFontsGetSuggestions(): void + { + $asked = []; + $sanitize = static function (string $key, mixed $value) use (&$asked): mixed { + $asked[] = $key; + + return $value; + }; + $error = $this->ability(sanitize: $sanitize)->execute(['set' => [ + 'heading_font' => 'robotto', + 'body_font' => 'Xyzzy Grotesk', + 'primary_nav_font' => 'Lato', + ]]); + + self::assertToolError($error, 'validation_failed'); + $fields = self::errorFields($error); + self::assertSame(['set.heading_font', 'set.body_font'], array_keys($fields)); + self::assertStringContainsString('Roboto', $fields['set.heading_font']); + self::assertStringContainsString('lit/divi-fonts-upload', $fields['set.body_font']); + self::assertSame(['primary_nav_font'], $asked, 'only the known font goes to Divi'); + } + + /** Divi's sanitizer answers false or changes the value – Divi would store false or round (Global Constraints). */ + public function testValuesDiviRejectsOrChangesAreErrors(): void + { + $sanitize = static fn (string $key, mixed $value): mixed => match ($key) { + 'body_header_style' => false, + 'content_width' => 1000, + default => $value, + }; + $error = $this->ability(sanitize: $sanitize)->execute(['set' => [ + 'body_header_style' => 'italic', + 'content_width' => 1200, + 'row_padding' => 3, + ]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame(['set.body_header_style', 'set.content_width'], array_keys(self::errorFields($error))); + } + + /** Ruling W4: the value is stored with the type Divi's sanitizer returns. */ + public function testTheSanitizedTypeIsStored(): void + { + $sanitize = static fn (string $key, mixed $value): mixed + => $key === 'body_font_size' ? (string) $value : $value; + + $this->confirmed($this->ability(self::snapshots(), $sanitize), ['set' => ['body_font_size' => 20]]); + + self::assertSame('20', $this->stored['body_font_size']); + } + + /** Ruling W3: a tablet/phone size equal to the size it inherits stays unset and keeps following it. */ + public function testATabletSizeEqualToTheInheritedSizeIsNoChange(): void + { + $same = $this->ability()->execute(['set' => ['tablet_body_font_size' => 16]]); + self::assertSame([], $same['diff']); + self::assertStringStartsWith('Keine Änderung', $same['summary']); + + $dry = self::assertToolError( + $this->ability()->execute(['set' => ['tablet_body_font_size' => 20, 'body_font_size' => 20]]), + 'confirm_required' + ); + self::assertSame(['body_font_size'], array_column($dry['diff'], 'key'), 'inherits the new desktop size'); + + $own = self::assertToolError( + $this->ability()->execute(['set' => ['tablet_body_font_size' => 15]]), + 'confirm_required' + ); + self::assertSame([['tablet_body_font_size', 'added']], array_map( + static fn (array $row): array => [$row['key'], $row['action']], + $own['diff'] + )); + self::assertSame([], $this->writes); + } + + public function testUnchangedValuesAreNoChange(): void + { + $result = $this->ability()->execute(['set' => ['body_font_size' => 16, 'gutter_width' => null]]); + + self::assertSame([], $result['diff']); + self::assertSame([], $this->writes); + } + + public function testConfirmNeedsTheDesignRevOfTheDryRun(): void + { + $input = ['set' => ['body_font_size' => 18], 'confirm' => true]; + + self::assertToolError($this->ability()->execute($input), 'validation_failed'); + self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch'); + self::assertSame([], $this->writes); + } + + public function testAFailedWriteIsRolledBackToTheSnapshot(): void + { + $calls = []; + $options = Mockery::mock(ThemeOptionsAdapter::class); + $options->allows('canCustomize')->andReturn(true); + $options->allows('read')->andReturn(ThemeOptionsAreaTest::stored()); + $options->allows('sanitize')->andReturnUsing(static fn (string $key, mixed $value): mixed => $value); + $options->allows('write')->andReturnUsing(static function (array $set, array $unset) use (&$calls): array { + $calls[] = $set; + if (count($calls) === 1) { + throw new RuntimeException('Zurücklesen stimmt nicht'); + } + + return $set; + }); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('theme_options-1'); + + try { + $this->confirmed($this->ability($snapshots, options: $options), ['set' => ['body_font_size' => 18]]); + self::fail('expected WriteFailed'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame('theme_options-1', $e->details['snapshot_id']); + } + self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->data(), $calls[1]); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/FontCheckTest.php b/tests/Unit/Integrations/Divi/Design/FontCheckTest.php new file mode 100644 index 0000000..f1980e5 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/FontCheckTest.php @@ -0,0 +1,57 @@ +allows('names')->andReturn(self::NAMES); + $fonts->allows('has')->andReturnUsing(static fn (string $name): bool => in_array($name, self::NAMES, true)); + + return $fonts; + } + + public function testAKnownFontIsFine(): void + { + $fonts = self::catalog(); + $fonts->shouldNotReceive('names'); + + self::assertNull(FontCheck::problem($fonts, 'Roboto Mono')); + } + + /** Ruling W5: the name is exact – "roboto" is unknown, but gets "Roboto" as suggestion. */ + public function testAnUnknownFontGetsSimilarNames(): void + { + self::assertSame( + sprintf( + 'unbekannte Schrift „roboto“; ähnlich: %s', + implode(', ', FontSuggest::similar('roboto', self::NAMES)) + ), + FontCheck::problem(self::catalog(), 'roboto') + ); + self::assertStringContainsString('Roboto', (string) FontCheck::problem(self::catalog(), 'roboto')); + } + + public function testWithoutSimilarNamesTheUploadToolIsNamed(): void + { + self::assertSame( + 'unbekannte Schrift „Xyzzy Grotesk“; eigene Schriften lädt lit/divi-fonts-upload hoch', + FontCheck::problem(self::catalog(), 'Xyzzy Grotesk') + ); + } +}