From 1bc8ce65b75f0c75f9ae1cbb7e498e41def8535f Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 17:51:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ThemeOptionsArea=20=E2=80=93=20Bereich?= =?UTF-8?q?=20theme=5Foptions=20ersetzt=20die=20Whitelist,=20pr=C3=BCft=20?= =?UTF-8?q?das=20Zur=C3=BCcklesen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../Divi/Design/ThemeOptionsArea.php | 118 ++++++++++++++ .../Divi/Design/ThemeOptionsAreaTest.php | 145 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 src/Integrations/Divi/Design/ThemeOptionsArea.php create mode 100644 tests/Unit/Integrations/Divi/Design/ThemeOptionsAreaTest.php diff --git a/src/Integrations/Divi/Design/ThemeOptionsArea.php b/src/Integrations/Divi/Design/ThemeOptionsArea.php new file mode 100644 index 0000000..91ffb41 --- /dev/null +++ b/src/Integrations/Divi/Design/ThemeOptionsArea.php @@ -0,0 +1,118 @@ +options->read())->data(); + } + + public function rev(array $raw): string + { + return ThemeOptionBook::fromDivi($raw)->rev(); + } + + /** $mayRemove has no effect: a key missing from the planned inventory is a reset to Divi's default (W3). */ + public function write(array $raw, bool $mayRemove): array + { + $planned = ThemeOptionBook::fromDivi($raw)->data(); + $saved = $this->options->write( + $planned, + array_values(array_diff(ThemeOptionSpec::KEYS, array_keys($planned))) + ); + + return ThemeOptionBook::fromDivi($saved)->data(); + } + + public function restore(array $raw): array + { + return $this->write($raw, true); + } + + public function verify(array $expected, array $actual): bool + { + $expected = ThemeOptionBook::fromDivi($expected)->data(); + $actual = ThemeOptionBook::fromDivi($actual)->data(); + if (array_keys($expected) !== array_keys($actual)) { + return false; + } + foreach ($expected as $key => $value) { + if (! self::same($value, $actual[$key])) { + return false; + } + } + + return true; + } + + public function differences(array $expected, array $actual): array + { + $actual = ThemeOptionBook::fromDivi($actual)->data(); + $warnings = []; + foreach (ThemeOptionBook::fromDivi($expected)->data() as $key => $value) { + if (array_key_exists($key, $actual) && $actual[$key] !== $value) { + $warnings[] = sprintf( + 'Divi hat die Theme-Option „%s“ als %s statt %s gespeichert.', + ThemeOptionSpec::definition($key)['label'], + self::shown($actual[$key]), + self::shown($value) + ); + } + } + + return $warnings; + } + + /** Whether Divi keeps $b as $a: equal, or the same number of another type (absint, floatval, a digit string). */ + public static function same(mixed $a, mixed $b): bool + { + return $a === $b || (is_numeric($a) && is_numeric($b) && (float) $a === (float) $b); + } + + private static function shown(int|float|string|false $value): string + { + return match (true) { + is_string($value) => '„' . $value . '“', + $value === false => 'false', + default => (string) $value, + }; + } +} diff --git a/tests/Unit/Integrations/Divi/Design/ThemeOptionsAreaTest.php b/tests/Unit/Integrations/Divi/Design/ThemeOptionsAreaTest.php new file mode 100644 index 0000000..3f2e5e5 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/ThemeOptionsAreaTest.php @@ -0,0 +1,145 @@ + + */ + public static function stored(): array + { + return ['body_font' => 'Open Sans', 'body_font_size' => 16, 'body_font_height' => 1.8]; + } + + /** + * A ThemeOptionsAdapter over $stored: write() sets and deletes like et_update_option/et_delete_option, notes each + * call in $writes and returns the read-back; sanitize() runs $sanitize (default: Divi keeps the value). + * + * @param array $stored + * @param list, 1: list}> $writes + * @param (Closure(string, mixed): mixed)|null $sanitize + */ + public static function adapter( + array &$stored, + array &$writes, + ?Closure $sanitize = null, + bool $customize = true + ): ThemeOptionsAdapter { + $options = Mockery::mock(ThemeOptionsAdapter::class); + $options->allows('canCustomize')->andReturn($customize); + $options->allows('read')->andReturnUsing(static function () use (&$stored): array { + return $stored; + }); + $options->allows('sanitize') + ->andReturnUsing($sanitize ?? static fn (string $key, mixed $value): mixed => $value); + $options->allows('write')->andReturnUsing( + static function (array $set, array $unset) use (&$stored, &$writes): array { + $writes[] = [$set, $unset]; + $stored = array_diff_key(array_merge($stored, $set), array_flip($unset)); + + return $stored; + } + ); + + return $options; + } + + private function area(array &$stored, array &$writes): ThemeOptionsArea + { + return new ThemeOptionsArea(self::adapter($stored, $writes)); + } + + public function testAreaReadsThroughTheAdapter(): void + { + $stored = self::stored(); + $writes = []; + $area = $this->area($stored, $writes); + + self::assertSame(['theme_options', 'Theme-Optionen'], [$area->name(), $area->label()]); + self::assertSame(DiviFeature::ThemeOptionWrite, $area->feature()); + self::assertSame(ThemeOptionBook::fromDivi($stored)->data(), $area->raw()); + self::assertSame(ThemeOptionBook::fromDivi($stored)->rev(), $area->rev($stored)); + } + + /** Ruling W6: keys of the planned inventory are set, whitelist keys missing from it are deleted. */ + public function testWriteSetsThePlannedKeysAndDeletesTheMissingOnes(): void + { + $stored = self::stored(); + $writes = []; + $planned = ['heading_font' => 'Roboto', 'body_font' => 'Open Sans', 'body_font_size' => 18]; + + $saved = $this->area($stored, $writes)->write($planned, false); + + self::assertCount(1, $writes); + self::assertSame(ThemeOptionBook::fromDivi($planned)->data(), $writes[0][0]); + self::assertSame(array_values(array_diff(ThemeOptionSpec::KEYS, array_keys($planned))), $writes[0][1]); + self::assertContains('body_font_height', $writes[0][1]); + self::assertSame(ThemeOptionBook::fromDivi($planned)->data(), $saved); + self::assertArrayNotHasKey('body_font_height', $stored); + } + + /** Ruling W6: a restore (and a rollback) replaces the whole whitelist with the snapshot. */ + public function testRestoreReplacesTheWholeWhitelistWithTheSnapshot(): void + { + $stored = ['body_font_size' => 20, 'tablet_body_font_size' => 18, 'gutter_width' => 2]; + $writes = []; + $snapshot = ThemeOptionBook::fromDivi(self::stored())->data(); + + $saved = $this->area($stored, $writes)->restore($snapshot); + + self::assertSame($snapshot, $saved); + self::assertSame($snapshot, ThemeOptionBook::fromDivi($stored)->data()); + self::assertContains('tablet_body_font_size', $writes[0][1]); + self::assertContains('gutter_width', $writes[0][1]); + } + + /** verify() tolerates only a number stored as another type (15 / "15" / 15.0), nothing else. */ + public function testVerifyInsistsOnTheSameKeysAndValues(): void + { + $stored = self::stored(); + $writes = []; + $area = $this->area($stored, $writes); + $expected = self::stored(); + + self::assertTrue($area->verify($expected, $expected)); + self::assertTrue($area->verify($expected, ['body_font_size' => '16', 'body_font_height' => '1.8'] + $expected)); + self::assertFalse($area->verify($expected, ['body_font_size' => 15] + $expected), 'changed value'); + self::assertFalse($area->verify($expected, ['body_font' => 'open sans'] + $expected), 'fonts are exact'); + $missing = $expected; + unset($missing['body_font_height']); + self::assertFalse($area->verify($expected, $missing), 'missing key'); + self::assertFalse($area->verify($expected, $expected + ['gutter_width' => 3]), 'added key'); + self::assertFalse($area->verify(['heading_font' => false], ['heading_font' => '']), 'false is not ""'); + } + + public function testDifferencesNameTheValuesDiviStoredAsAnotherType(): void + { + $stored = self::stored(); + $writes = []; + $area = $this->area($stored, $writes); + $expected = self::stored(); + + self::assertSame([], $area->differences($expected, $expected)); + self::assertSame( + [sprintf( + 'Divi hat die Theme-Option „%s“ als „16“ statt 16 gespeichert.', + ThemeOptionSpec::definition('body_font_size')['label'] + )], + $area->differences($expected, ['body_font_size' => '16'] + $expected) + ); + } +}