feat: Fähigkeiten color-write/variable-write; GlobalColorsAdapter und GlobalVariablesAdapter (Manifest, Signatur-Referenzen) und DesignRights::colors()/variables()

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 13:55:09 +02:00
co-authored by Claude Sonnet 5.5
parent d73144ab36
commit 9b7c3c1d3b
12 changed files with 537 additions and 19 deletions
@@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignRights;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
@@ -26,4 +27,50 @@ final class DesignRightsTest extends TestCase
$refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden');
self::assertStringContainsString('edit_theme_options', $refusal['message']);
}
public function testColorsNeedEditThemeOptionsAndTheVisualBuilder(): void
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('canUseVisualBuilder')->andReturn(true, false);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
self::assertNull(DesignRights::colors($writer));
$refusal = self::assertToolError(DesignRights::colors($writer), 'forbidden');
self::assertStringContainsString('keine Farben', $refusal['message']);
Functions\when('current_user_can')->justReturn(false);
$refusal = self::assertToolError(DesignRights::colors($writer), 'forbidden');
self::assertStringContainsString('Farben ändern erfordert', $refusal['message']);
self::assertStringContainsString('edit_theme_options', $refusal['message']);
}
public function testVariablesAlsoNeedTheVariablesManager(): void
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('canUseVisualBuilder')->andReturn(true);
$variables = Mockery::mock(GlobalVariablesAdapter::class);
$variables->allows('canManage')->andReturn(true, false);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
self::assertNull(DesignRights::variables($writer, $variables));
$refusal = self::assertToolError(DesignRights::variables($writer, $variables), 'forbidden');
self::assertStringContainsString('Variablen-Manager', $refusal['message']);
self::assertStringContainsString('Rollen-Editor', $refusal['hint']);
}
public function testVariablesAskTheThemeRightAndTheVisualBuilderFirst(): void
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('canUseVisualBuilder')->andReturn(false);
$variables = Mockery::mock(GlobalVariablesAdapter::class);
$variables->allows('canManage')->andReturn(true);
Functions\when('current_user_can')->justReturn(true);
$refusal = self::assertToolError(DesignRights::variables($writer, $variables), 'forbidden');
self::assertStringContainsString('keine Variablen', $refusal['message']);
Functions\when('current_user_can')->justReturn(false);
$refusal = self::assertToolError(DesignRights::variables($writer, $variables), 'forbidden');
self::assertStringContainsString('Variablen ändern erfordert', $refusal['message']);
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
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 Mockery;
use RuntimeException;
/**
* Divi's GlobalData is replaced by a Mockery alias; the tests that need it run in their own process (an alias
* defines the class for the rest of the process).
*/
final class GlobalColorsAdapterTest extends TestCase
{
private const GLOBAL_DATA = 'ET\\Builder\\Packages\\GlobalData\\GlobalData';
public function testIsARegisteredDesignAdapterForColorWrite(): void
{
$manifest = GlobalColorsAdapter::manifest();
self::assertContains(GlobalColorsAdapter::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::DESIGN, $manifest->area);
self::assertSame(
['GlobalData::get_global_colors()', 'GlobalData::sanitize_global_colors_data()',
'GlobalData::set_global_colors()'],
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
);
self::assertSame([0, 1, 2], array_map(static fn (Call $call): ?int => $call->args, $manifest->calls));
self::assertSame(['et_divi'], $manifest->keys);
self::assertSame([DiviFeature::ColorWrite], $manifest->features);
}
public function testWritesNeedAReleasedDivi(): void
{
ReleaseGate::useResolver(
static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0')
);
$this->expectException(DiviLocked::class);
(new GlobalColorsAdapter())->save(['gcid-abc' => ['color' => '#fff']], false);
}
/** @runInSeparateProcess */
public function testReadLaysTheStoredCustomizerValuesOverDivisCache(): void
{
Mockery::mock('alias:' . self::GLOBAL_DATA)->allows('get_global_colors')->andReturn([
'gcid-primary-color' => ['color' => '#2ea3f2', 'folder' => 'customizer', 'status' => 'active'],
'gcid-secondary-color' => ['color' => '#2ea3f2', 'folder' => 'customizer', 'status' => 'active'],
'gcid-body-color' => ['color' => '#666666', 'folder' => 'customizer', 'status' => 'active'],
'gcid-abc1234567' => ['color' => '#123456', 'folder' => '', 'status' => 'active'],
]);
Functions\when('get_option')->alias(static fn (string $name): mixed => $name === 'et_divi'
? ['accent_color' => '#ff0000', 'secondary_accent_color' => '', 'font_color' => 7, 'link_color' => '#00f']
: false);
$colors = (new GlobalColorsAdapter())->read();
self::assertSame('#ff0000', $colors['gcid-primary-color']['color'], 'stored option wins over the cache');
self::assertSame('#2ea3f2', $colors['gcid-secondary-color']['color'], 'an empty option is not laid over');
self::assertSame('#666666', $colors['gcid-body-color']['color'], 'a non-string option is not laid over');
self::assertArrayNotHasKey('gcid-link-color', $colors, 'no entry is invented');
self::assertSame('#123456', $colors['gcid-abc1234567']['color']);
}
/** @runInSeparateProcess */
public function testReadWithoutThemeOptionsIsDivisAnswer(): void
{
$divi = ['gcid-primary-color' => ['color' => '#2ea3f2', 'folder' => 'customizer']];
Mockery::mock('alias:' . self::GLOBAL_DATA)->allows('get_global_colors')->andReturn($divi);
Functions\when('get_option')->justReturn(false);
self::assertSame($divi, (new GlobalColorsAdapter())->read());
}
/** @runInSeparateProcess */
public function testSaveSanitizesAndMergesOrReplaces(): void
{
$entries = ['gcid-abc1234567' => ['color' => '#123456', 'status' => 'active', 'usedInPosts' => []]];
$clean = ['gcid-abc1234567' => ['color' => '#123456', 'status' => 'active', 'usedInPosts' => []]];
$divi = Mockery::mock('alias:' . self::GLOBAL_DATA);
$divi->expects('sanitize_global_colors_data')->twice()->with($entries)->andReturn($clean);
// Normal write: merge ("already sanitized" = true); restore: replace all (false).
$divi->expects('set_global_colors')->once()->with($clean, true)->ordered();
$divi->expects('set_global_colors')->once()->with($clean, false)->ordered();
(new GlobalColorsAdapter())->save($entries, false);
(new GlobalColorsAdapter())->save($entries, true);
}
/** @runInSeparateProcess */
public function testSaveRefusesWhenDiviDropsAnEntry(): void
{
$divi = Mockery::mock('alias:' . self::GLOBAL_DATA);
$divi->allows('sanitize_global_colors_data')->andReturn(['gcid-abc1234567' => ['color' => '#123456']]);
$divi->expects('set_global_colors')->never();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('global colors refused: Divi dropped gcid-empty00000');
(new GlobalColorsAdapter())->save([
'gcid-abc1234567' => ['color' => '#123456'],
'gcid-empty00000' => ['color' => ''],
], false);
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
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 Mockery;
use RuntimeException;
use WP_Error;
/**
* Divi's GlobalData is replaced by a Mockery alias and et_pb_is_allowed() by Brain Monkey; those tests run in their
* own process (both define a symbol for the rest of the process).
*/
final class GlobalVariablesAdapterTest extends TestCase
{
private const GLOBAL_DATA = 'ET\\Builder\\Packages\\GlobalData\\GlobalData';
public function testIsARegisteredDesignAdapterForVariableWrite(): void
{
$manifest = GlobalVariablesAdapter::manifest();
self::assertContains(GlobalVariablesAdapter::class, DiviAdapters::CLASSES);
self::assertSame(Manifest::DESIGN, $manifest->area);
self::assertSame(
['GlobalData::get_global_variables()', 'GlobalData::set_global_variables()', 'et_pb_is_allowed()'],
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
);
self::assertSame([0, 2, 1], array_map(static fn (Call $call): ?int => $call->args, $manifest->calls));
self::assertSame([], $manifest->keys);
self::assertSame([DiviFeature::VariableWrite], $manifest->features);
}
public function testWritesNeedAReleasedDivi(): void
{
ReleaseGate::useResolver(
static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0')
);
$this->expectException(DiviLocked::class);
(new GlobalVariablesAdapter())->save([], []);
}
public function testWithoutDiviNobodyManagesVariables(): void
{
// Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed".
self::assertFalse((new GlobalVariablesAdapter())->canManage());
}
/** @runInSeparateProcess */
public function testCanManageAsksTheVariablesManagerRight(): void
{
Functions\when('et_pb_is_allowed')->alias(static fn (string $cap): bool => $cap === 'variables_manager');
self::assertTrue((new GlobalVariablesAdapter())->canManage());
}
/** @runInSeparateProcess */
public function testReadReturnsTheStoredForm(): void
{
$actions = ['allowedActions' => ['editLabel' => true, 'editValue' => true, 'remove' => true]];
$space = ['label' => 'Abstand', 'value' => '24px', 'order' => '1', 'status' => 'active'];
$wave = ['label' => 'Welle', 'value' => 'linear-gradient(#fff,#000)', 'order' => '1', 'status' => 'active'];
Mockery::mock('alias:' . self::GLOBAL_DATA)->allows('get_global_variables')->andReturn([
'numbers' => (object) ['gvid-space00001' => $space + $actions],
'strings' => (object) [],
'colors' => (object) ['gcid-x' => ['label' => 'X'] + $actions],
'fonts' => (object) [
'--et_global_heading_font' => ['label' => 'Überschrift', 'value' => 'Open Sans'] + $actions,
'gvid-font000001' => (object) (['label' => 'Fließtext', 'value' => 'Inter'] + $actions),
],
'gradients' => (object) ['gvid-wave000001' => $wave + $actions],
]);
self::assertSame(
[
'numbers' => ['gvid-space00001' => $space],
'fonts' => ['gvid-font000001' => ['label' => 'Fließtext', 'value' => 'Inter']],
'gradients' => ['gvid-wave000001' => $wave],
],
(new GlobalVariablesAdapter())->read()
);
}
/** @runInSeparateProcess */
public function testSavePassesThePlannedInventoryAndThrowsOnARefusal(): void
{
$data = ['numbers' => ['gvid-space00001' => ['label' => 'Abstand', 'value' => '32px']]];
$expected = ['numbers' => ['gvid-space00001' => ['label' => 'Abstand', 'value' => '24px']]];
Mockery::mock('alias:' . self::GLOBAL_DATA)->expects('set_global_variables')->twice()->with($data, $expected)
->andReturn(null, new WP_Error('global_variables_stale', 'Global variables changed.'));
(new GlobalVariablesAdapter())->save($data, $expected);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('global variables refused: global_variables_stale Global variables changed.');
(new GlobalVariablesAdapter())->save($data, $expected);
}
}