From 122548db12114999cbdc05b154c6b915e6220780 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 17:51:18 +0200 Subject: [PATCH] feat: lit/divi-theme-options-get liest Wert, wirksamen Wert, Standard und Bereich je Theme-Option Co-Authored-By: Claude Sonnet 5.5 --- .../Design/DiviThemeOptionsGetAbility.php | 172 ++++++++++++++++++ .../Design/DiviThemeOptionsGetAbilityTest.php | 134 ++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/Integrations/Divi/Design/DiviThemeOptionsGetAbility.php create mode 100644 tests/Unit/Integrations/Divi/Design/DiviThemeOptionsGetAbilityTest.php diff --git a/src/Integrations/Divi/Design/DiviThemeOptionsGetAbility.php b/src/Integrations/Divi/Design/DiviThemeOptionsGetAbility.php new file mode 100644 index 0000000..386b263 --- /dev/null +++ b/src/Integrations/Divi/Design/DiviThemeOptionsGetAbility.php @@ -0,0 +1,172 @@ + 'object', + 'properties' => [ + 'keys' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'enum' => ThemeOptionSpec::KEYS], + 'description' => 'Nur diese Theme-Optionen liefern (Standard: alle).', + ], + ], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'options' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'key' => ['type' => 'string'], + 'label' => ['type' => 'string'], + 'type' => ['type' => 'string'], + 'is_default' => ['type' => 'boolean'], + ], + 'required' => ['key', 'label', 'type', 'value', 'effective', 'default', 'is_default'], + ], + ], + 'design_rev' => ['type' => 'string'], + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['options', 'design_rev', 'notes'], + ]; + } + + public function annotations(): array + { + return Annotations::read(); + } + + public function isDirect(): bool + { + return false; + } + + /** Reads et_divi and the Customizer table (ThemeOptionsAdapter, ThemeOptionSpec mirror). */ + public function diviFeatures(array $input): array + { + return [DiviFeature::ThemeOptionRead]; + } + + public function permission(array $input): bool|WP_Error + { + return self::rights() ?? true; + } + + public function execute(array $input): mixed + { + $refusal = self::rights(); + if ($refusal !== null) { + return $refusal; + } + $keys = self::keys($input); + if ($keys instanceof WP_Error) { + return $keys; + } + + $book = ThemeOptionBook::fromDivi($this->options->read()); + $rows = []; + foreach ($keys as $key) { + $definition = ThemeOptionSpec::definition($key); + $rows[] = [ + 'key' => $key, + 'label' => $definition['label'], + 'type' => $definition['type'], + 'value' => $book->get($key), + 'effective' => ThemeOptionSpec::effective($key, $book->data()), + 'default' => ThemeOptionSpec::effective($key, $book->without($key)->data()), + 'is_default' => ! $book->has($key), + ] + array_intersect_key($definition, array_flip(self::RULES)); + } + + return ['options' => $rows, 'design_rev' => $book->rev(), 'notes' => self::NOTES]; + } + + /** Like lit/divi-design-system: whoever edits content may read the design. */ + private static function rights(): ?WP_Error + { + return current_user_can('edit_posts') ? null : Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.'); + } + + /** + * @param array $input + * @return list|WP_Error The requested keys in ThemeOptionSpec::KEYS order (all without "keys"). + */ + private static function keys(array $input): array|WP_Error + { + $keys = $input['keys'] ?? ThemeOptionSpec::KEYS; + if (! is_array($keys) || ! array_is_list($keys)) { + return Errors::invalidInput('keys muss eine Liste sein.', ['keys' => 'Liste von Theme-Optionen']); + } + $errors = []; + foreach ($keys as $index => $key) { + if (! is_string($key) || ! ThemeOptionSpec::isKey($key)) { + $errors[sprintf('keys[%d]', $index)] = 'unbekannte Theme-Option; erlaubt sind die Schlüssel aus ' + . 'lit/divi-theme-options-get ohne keys'; + } + } + if ($errors !== []) { + return Errors::invalidInput('Unbekannte Theme-Optionen.', $errors); + } + + return array_values(array_intersect(ThemeOptionSpec::KEYS, $keys)); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/DiviThemeOptionsGetAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviThemeOptionsGetAbilityTest.php new file mode 100644 index 0000000..e357a75 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/DiviThemeOptionsGetAbilityTest.php @@ -0,0 +1,134 @@ +justReturn(true); + } + + private static function ability(): DiviThemeOptionsGetAbility + { + $stored = ThemeOptionsAreaTest::stored(); + $writes = []; + + return new DiviThemeOptionsGetAbility(ThemeOptionsAreaTest::adapter($stored, $writes)); + } + + /** + * @param array $result + * @return array> key => row + */ + private static function byKey(array $result): array + { + return array_column($result['options'], null, 'key'); + } + + public function testSchemaAndMeta(): void + { + $ability = self::ability(); + $schema = $ability->inputSchema(); + + self::assertSame('lit/divi-theme-options-get', $ability->name()); + self::assertFalse($ability->isDirect()); + self::assertSame(Annotations::read(), $ability->annotations()); + self::assertSame([DiviFeature::ThemeOptionRead], $ability->diviFeatures([])); + self::assertSame(ThemeOptionSpec::KEYS, $schema['properties']['keys']['items']['enum']); + self::assertFalse($schema['additionalProperties']); + self::assertSame(['options', 'design_rev', 'notes'], $ability->outputSchema()['required']); + self::assertStringContainsString('lit/divi-theme-options-update', $ability->description()); + } + + /** Reading needs what lit/divi-design-system needs (edit_posts) – checked before Divi is asked. */ + public function testNeedsEditPosts(): void + { + Functions\when('current_user_can')->justReturn(false); + $options = Mockery::mock(ThemeOptionsAdapter::class); + $options->shouldNotReceive('read'); + $ability = new DiviThemeOptionsGetAbility($options); + + self::assertToolError($ability->permission([]), 'forbidden'); + self::assertToolError($ability->execute([]), 'forbidden'); + } + + public function testListsEveryOptionWithValueEffectiveValueAndDefault(): void + { + $result = self::ability()->execute([]); + + self::assertSame(ThemeOptionSpec::KEYS, array_column($result['options'], 'key')); + self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->rev(), $result['design_rev']); + $rows = self::byKey($result); + $size = ThemeOptionSpec::definition('body_font_size'); + $expected = ['key' => 'body_font_size', 'label' => $size['label'], 'type' => 'int', 'value' => 16, + 'effective' => 16, 'default' => ThemeOptionSpec::effective('body_font_size', []), 'is_default' => false, + 'min' => $size['min'], 'max' => $size['max'], 'step' => $size['step']]; + $actual = $rows['body_font_size']; + ksort($expected); + ksort($actual); + self::assertSame($expected, $actual); + self::assertSame('Open Sans', $rows['body_font']['value']); + } + + /** Ruling W3: an unset tablet size inherits the desktop size – as effective value and as its default. */ + public function testUnsetSizesInheritTheLargerSize(): void + { + $tablet = self::byKey(self::ability()->execute([]))['tablet_body_font_size']; + + self::assertNull($tablet['value']); + self::assertTrue($tablet['is_default']); + self::assertSame(16, $tablet['effective']); + self::assertSame(16, $tablet['default']); + self::assertSame('body_font_size', $tablet['inherits']); + } + + public function testChoicesAreListed(): void + { + $rows = self::byKey(self::ability()->execute([])); + + self::assertSame( + ThemeOptionSpec::definition('body_header_style')['choices'], + $rows['body_header_style']['choices'] + ); + self::assertArrayNotHasKey('choices', $rows['body_font']); + } + + public function testKeysNarrowTheAnswerInTheFixedOrder(): void + { + $result = self::ability()->execute(['keys' => ['row_padding', 'body_font']]); + + self::assertSame(['body_font', 'row_padding'], array_column($result['options'], 'key')); + } + + public function testUnknownKeysAreRefused(): void + { + $error = self::ability()->execute(['keys' => ['accent_color', 'body_font', 5]]); + + self::assertToolError($error, 'validation_failed'); + self::assertSame(['keys[0]', 'keys[2]'], array_keys(self::errorFields($error))); + self::assertToolError(self::ability()->execute(['keys' => 'body_font']), 'validation_failed'); + } + + /** G3, A8: the main colors are not theme options here; the notes point to lit/divi-colors-set. */ + public function testNotesPointToTheColorTool(): void + { + $notes = implode(' ', self::ability()->execute([])['notes']); + + self::assertStringContainsString('lit/divi-colors-set', $notes); + self::assertStringContainsString('null', $notes); + } +}