feat: ThemeOptionsArea – Bereich theme_options ersetzt die Whitelist, prüft das Zurücklesen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
05ff2d90f3
commit
1bc8ce65b7
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The area "theme_options" (spec §8.1, rulings W2, W3, W6): the 20 whitelisted Divi theme options as et_divi stores
|
||||||
|
* them (ThemeOptionBook); a key missing from the inventory is unset, i.e. at Divi's default. Every write replaces the
|
||||||
|
* whole whitelist through ThemeOptionsAdapter::write() – keys of the inventory are set, whitelist keys missing from it
|
||||||
|
* are deleted – so a change, a restore and a rollback all leave exactly the given inventory. verify() insists on the
|
||||||
|
* same keys with the same values and only tolerates a number stored as another type (15, "15", 15.0);
|
||||||
|
* differences() names those.
|
||||||
|
*/
|
||||||
|
final class ThemeOptionsArea implements DesignArea
|
||||||
|
{
|
||||||
|
/** Ruling W7: theme options have no page reference; every plan warns about their reach instead. */
|
||||||
|
public const SITE_WIDE = 'Theme-Optionen wirken auf die gesamte Website.';
|
||||||
|
|
||||||
|
public function __construct(private ThemeOptionsAdapter $options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'theme_options';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Theme-Optionen';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function feature(): DiviFeature
|
||||||
|
{
|
||||||
|
return DiviFeature::ThemeOptionWrite;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function raw(): array
|
||||||
|
{
|
||||||
|
return ThemeOptionBook::fromDivi($this->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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsArea;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
|
||||||
|
final class ThemeOptionsAreaTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* ThemeOptionsAdapter::read(): body font, text size and line height set; everything else at Divi's default.
|
||||||
|
*
|
||||||
|
* @return array<string, int|float|string|false>
|
||||||
|
*/
|
||||||
|
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<string, int|float|string|false> $stored
|
||||||
|
* @param list<array{0: array<string, int|float|string|false>, 1: list<string>}> $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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user