feat: Fähigkeiten theme-option-read/-write und font-upload; ThemeOptionsAdapter und FontCatalog (Manifest, Nachbau customizer-typography, Signatur-Referenzen) und DesignRights::themeOptions()/fonts()

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 17:44:57 +02:00
co-authored by Claude Sonnet 5.5
parent 8379c55c89
commit a90cd7f5bd
14 changed files with 4765 additions and 26 deletions
@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
/**
* Divi's sanitizers, et_update_option(), et_delete_option(), et_pb_force_regenerate_templates() and
* et_pb_is_allowed() are faked with Brain Monkey; those tests run in their own process (Brain Monkey defines a missing
* function for the rest of the process, and other tests rely on Divi's functions being absent). ET_Core_PageResource
* is the recording stub of tests/Stubs.
*/
final class ThemeOptionsAdapterTest extends TestCase
{
/** Plan 4d W2, in this order. */
private const KEYS = [
'heading_font', 'body_font', 'primary_nav_font', 'secondary_nav_font', 'heading_font_weight',
'body_font_weight', 'body_font_size', 'tablet_body_font_size', 'phone_body_font_size', 'body_header_size',
'tablet_header_font_size', 'phone_header_font_size', 'body_font_height', 'body_header_height',
'body_header_spacing', 'body_header_style', 'content_width', 'gutter_width', 'section_padding', 'row_padding',
];
public function testIsARegisteredDesignAdapterForThemeOptions(): void
{
$manifest = ThemeOptionsAdapter::manifest();
self::assertContains(ThemeOptionsAdapter::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::DESIGN, $manifest->area);
self::assertSame(
[
'et_sanitize_font_choices()', 'et_sanitize_global_font_weight()', 'et_sanitize_float_number()',
'et_sanitize_int_number()', 'et_divi_global_font_style()', 'et_update_option()', 'et_delete_option()',
'ET_Core_PageResource::remove_static_resources()', 'et_pb_force_regenerate_templates()',
'et_pb_is_allowed()',
],
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
);
self::assertSame(
[1, 1, 1, 1, 1, 2, 1, 2, 0, 1],
array_map(static fn (Call $call): ?int => $call->args, $manifest->calls)
);
self::assertSame(['et_divi'], $manifest->keys);
self::assertSame([DiviFeature::ThemeOptionWrite], $manifest->features);
self::assertCount(1, $manifest->mirrors);
$mirror = $manifest->mirrors[0];
self::assertSame('customizer-typography', $mirror->id);
self::assertSame('functions.php', $mirror->file);
self::assertSame('et_divi_customizer_theme_settings', $mirror->function);
self::assertNull($mirror->class);
self::assertSame(
[DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite],
$manifest->featuresOf($mirror)
);
}
public function testTheSanitizerTableCoversTheWhitelistWithDivisCallsOfTheManifest(): void
{
self::assertSame(self::KEYS, array_keys(ThemeOptionsAdapter::SANITIZERS));
$divi = array_values(array_unique(array_diff(ThemeOptionsAdapter::SANITIZERS, ['absint'])));
sort($divi);
self::assertSame(
[
'et_divi_global_font_style', 'et_sanitize_float_number', 'et_sanitize_font_choices',
'et_sanitize_global_font_weight', 'et_sanitize_int_number',
],
$divi
);
$calls = array_map(
static fn (Call $call): string => $call->symbol . '/' . $call->args,
ThemeOptionsAdapter::manifest()->calls
);
foreach ($divi as $sanitizer) {
self::assertContains($sanitizer . '/1', $calls, 'every Divi sanitizer is a call of the manifest');
}
}
public function testSanitizeRefusesAKeyOutsideTheWhitelist(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('not a theme option of the whitelist: accent_color');
(new ThemeOptionsAdapter())->sanitize('accent_color', '#ff0000');
}
public function testSizesGoThroughWordPressAbsintAndNonScalarsAreRefused(): void
{
$adapter = new ThemeOptionsAdapter();
self::assertSame(15, $adapter->sanitize('body_font_size', '15.7'), 'absint rounds, the caller compares');
self::assertSame(1200, $adapter->sanitize('content_width', 1200));
self::assertFalse($adapter->sanitize('row_padding', ['2']));
}
/** @runInSeparateProcess */
public function testSanitizeCallsDivisCustomizerSanitizerOfTheKey(): void
{
Functions\expect('et_sanitize_font_choices')->once()->with('Roboto')->andReturn('Roboto');
Functions\expect('et_sanitize_global_font_weight')->once()->with('600')->andReturn('600');
Functions\expect('et_sanitize_float_number')->once()->with('1.8')->andReturn(1.8);
Functions\expect('et_sanitize_int_number')->once()->with('-1')->andReturn(-1);
Functions\expect('et_divi_global_font_style')->once()->with('italic|uppercase')->andReturn(false);
$adapter = new ThemeOptionsAdapter();
self::assertSame('Roboto', $adapter->sanitize('secondary_nav_font', 'Roboto'));
self::assertSame('600', $adapter->sanitize('body_font_weight', '600'));
self::assertSame(1.8, $adapter->sanitize('body_header_height', '1.8'));
self::assertSame(-1, $adapter->sanitize('body_header_spacing', '-1'));
self::assertFalse($adapter->sanitize('body_header_style', 'italic|uppercase'), 'Divi refuses combinations');
}
public function testReadReturnsThePresentWhitelistKeysWithTheirStoredTypes(): void
{
Functions\when('get_option')->alias(static fn (string $name): mixed => $name === 'et_divi'
? [
'row_padding' => null,
'accent_color' => '#ff0000',
'body_font' => 'Roboto',
'body_font_height' => 1.8,
'gutter_width' => ['3'],
'heading_font' => false,
'body_font_size' => 15,
]
: false);
self::assertSame(
['heading_font' => false, 'body_font' => 'Roboto', 'body_font_size' => 15, 'body_font_height' => 1.8],
(new ThemeOptionsAdapter())->read(),
'whitelist only, in W2 order; values of another type are not theme option values'
);
}
public function testReadWithoutThemeOptionsIsEmpty(): void
{
Functions\when('get_option')->justReturn(false);
self::assertSame([], (new ThemeOptionsAdapter())->read());
}
public function testWritesNeedAReleasedDivi(): void
{
ReleaseGate::useResolver(
static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0')
);
$this->expectException(DiviLocked::class);
(new ThemeOptionsAdapter())->write(['body_font_size' => 16], []);
}
public function testWriteRefusesAKeyOutsideTheWhitelistBeforeWriting(): void
{
// Unit tests have no et_update_option(): reaching it would be an Error, not this exception.
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('not a theme option of the whitelist: accent_color, divi_logo');
(new ThemeOptionsAdapter())->write(['body_font_size' => 16, 'accent_color' => '#f00'], ['divi_logo']);
}
/** @runInSeparateProcess */
public function testWriteUpdatesAndDeletesPerKeyClearsTheCachesAndReadsBack(): void
{
$stored = ['body_font_size' => 14, 'heading_font' => 'Roboto', 'accent_color' => '#ff0000'];
$log = [];
Functions\when('get_option')->alias(static function (string $name) use (&$stored): mixed {
return $name === 'et_divi' ? $stored : false;
});
Functions\when('et_update_option')->alias(static function (string $key, mixed $value) use (
&$stored,
&$log
): void {
$stored[$key] = $value;
$log[] = 'update ' . $key;
});
Functions\when('et_delete_option')->alias(static function (string $key) use (&$stored, &$log): void {
unset($stored[$key]);
$log[] = 'delete ' . $key;
});
Functions\when('et_pb_force_regenerate_templates')->alias(static function () use (&$log): void {
$log[] = 'regenerate templates after ' . count(\ET_Core_PageResource::$removed) . ' static CSS clear';
});
\ET_Core_PageResource::$removed = [];
$result = (new ThemeOptionsAdapter())->write(
['body_font_size' => 16, 'body_font_height' => 1.8],
['heading_font']
);
self::assertSame(['body_font_size' => 16, 'body_font_height' => 1.8], $result);
self::assertSame(
[
'update body_font_size', 'update body_font_height', 'delete heading_font',
'regenerate templates after 1 static CSS clear',
],
$log
);
self::assertSame([['all', 'all', false]], \ET_Core_PageResource::$removed, 'like a customizer save');
self::assertSame('#ff0000', $stored['accent_color'], 'other theme options stay');
}
/** @runInSeparateProcess */
public function testWriteThrowsWhenTheReadBackDiffers(): void
{
$stored = ['body_font_size' => 14, 'heading_font' => 'Roboto'];
Functions\when('get_option')->alias(static fn (string $name): mixed => $name === 'et_divi' ? $stored : false);
// Divi silently saves nothing (e.g. a filter or another process).
Functions\when('et_update_option')->justReturn(null);
Functions\when('et_delete_option')->justReturn(null);
Functions\when('et_pb_force_regenerate_templates')->justReturn(null);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('theme options not written: body_font_size, heading_font');
(new ThemeOptionsAdapter())->write(['body_font_size' => 16], ['heading_font']);
}
public function testWithoutDiviNobodyCustomizesTheTheme(): void
{
// Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed".
self::assertFalse((new ThemeOptionsAdapter())->canCustomize());
}
/** @runInSeparateProcess */
public function testCanCustomizeAsksTheThemeCustomizerRight(): void
{
Functions\when('et_pb_is_allowed')->alias(static fn (string $cap): bool => $cap === 'theme_customizer');
self::assertTrue((new ThemeOptionsAdapter())->canCustomize());
}
}