Requirement, Call, ConstantRequirement, Mirror, Problem, FeatureStatus, CompatibilityMatrix, Manifest, ManifestCheck sowie Fingerprints und LineDiff liegen jetzt im divi-freien Kern. Neu: GlobalRequirement, CompatibilityMatrix::fromProblems(), ManifestCheck-Option signatures, DiviManifestCheck::create() und DiviAreas. Divi-Verhalten unverändert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
12 KiB
PHP
265 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use InvalidArgumentException;
|
|
use LIT\MCP\Compatibility\ReleaseState;
|
|
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
|
|
use LIT\MCP\Compatibility\Call;
|
|
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
|
|
use LIT\MCP\Integrations\Divi\Release\DiviAreas;
|
|
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
|
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
|
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
|
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(DiviAreas::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', 'et_theme_options'], $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 = [];
|
|
// Audit L5: Divi's request global may be stale; et_update_option() writes it back whole.
|
|
$GLOBALS['et_theme_options'] = ['stale' => true];
|
|
$seen = [];
|
|
Functions\when('et_update_option')->alias(static function (string $key, mixed $value) use (
|
|
&$stored,
|
|
&$log,
|
|
&$seen
|
|
): void {
|
|
$seen[] = $GLOBALS['et_theme_options'];
|
|
$stored[$key] = $value;
|
|
$log[] = 'update ' . $key;
|
|
});
|
|
|
|
$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(
|
|
['body_font_size' => 14, 'heading_font' => 'Roboto', 'accent_color' => '#ff0000'],
|
|
$seen[0],
|
|
'et_theme_options is reloaded from et_divi before the first write'
|
|
);
|
|
unset($GLOBALS['et_theme_options']);
|
|
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());
|
|
}
|
|
}
|