diff --git a/src/Integrations/Divi/Design/DesignRights.php b/src/Integrations/Divi/Design/DesignRights.php index 6fcabc0..bf6f484 100644 --- a/src/Integrations/Divi/Design/DesignRights.php +++ b/src/Integrations/Divi/Design/DesignRights.php @@ -11,9 +11,10 @@ use WP_Error; defined('ABSPATH') || exit; /** - * Who may change the design system (spec §10): presets need edit_theme_options and Divi's role right "Visual Builder - * verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1"). Without unfiltered_html a - * write is refused while presets it saves along hold HTML/CSS that Divi would strip (final review I-2). + * Who may change the design system (spec §10): presets and colors need edit_theme_options and Divi's role right + * "Visual Builder verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1"); variables + * also the role right "Variables Manager". Without unfiltered_html a write is refused while presets it saves along + * hold HTML/CSS that Divi would strip (final review I-2). */ final class DesignRights { @@ -48,15 +49,46 @@ final class DesignRights } public static function presets(DesignSystemWriter $writer): ?WP_Error + { + return self::designer($writer, 'Presets'); + } + + /** Colors: the same rights as presets (spec §10 – stricter than Divi, which asks edit_theme_options only). */ + public static function colors(DesignSystemWriter $writer): ?WP_Error + { + return self::designer($writer, 'Farben'); + } + + /** Variables: also Divi's role right "Variables Manager" – without it Divi silently saves nothing (plan 4c V7). */ + public static function variables(DesignSystemWriter $writer, GlobalVariablesAdapter $variables): ?WP_Error + { + $refusal = self::designer($writer, 'Variablen'); + if ($refusal !== null || $variables->canManage()) { + return $refusal; + } + + return Errors::forbidden( + 'Die Divi-Rolle dieses Kontos erlaubt den Variablen-Manager nicht; ohne ihn speichert Divi keine ' + . 'Variablen.', + 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.' + ); + } + + /** edit_theme_options and Divi's role right "Visual Builder verwenden"; $what names the area in the message. */ + private static function designer(DesignSystemWriter $writer, string $what): ?WP_Error { if (! current_user_can('edit_theme_options')) { - return Errors::forbidden( - 'Presets ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).' - ); + return Errors::forbidden(sprintf( + '%s ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).', + $what + )); } if (! $writer->canUseVisualBuilder()) { return Errors::forbidden( - 'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine Presets.', + sprintf( + 'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine %s.', + $what + ), 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.' ); } diff --git a/src/Integrations/Divi/Design/GlobalColorsAdapter.php b/src/Integrations/Divi/Design/GlobalColorsAdapter.php new file mode 100644 index 0000000..54a6b43 --- /dev/null +++ b/src/Integrations/Divi/Design/GlobalColorsAdapter.php @@ -0,0 +1,88 @@ + its option in et_divi (GlobalData::$customizer_colors). */ + private const CUSTOMIZER = [ + 'gcid-primary-color' => 'accent_color', + 'gcid-secondary-color' => 'secondary_accent_color', + 'gcid-heading-color' => 'header_color', + 'gcid-body-color' => 'font_color', + 'gcid-link-color' => 'link_color', + ]; + + public static function manifest(): Manifest + { + return new Manifest( + Manifest::DESIGN, + calls: [ + new Call(GlobalData::class . '::get_global_colors', 0), + new Call(GlobalData::class . '::sanitize_global_colors_data', 1), + new Call(GlobalData::class . '::set_global_colors', 2), + ], + keys: [self::OPTIONS], + features: [DiviFeature::ColorWrite] + ); + } + + /** @return array> get_global_colors() with the customizer values of et_divi. */ + public function read(): array + { + $colors = GlobalData::get_global_colors(); + $options = get_option(self::OPTIONS); + foreach (self::CUSTOMIZER as $id => $option) { + $value = is_array($options) ? ($options[$option] ?? null) : null; + if (is_array($colors[$id] ?? null) && is_string($value) && $value !== '') { + $colors[$id]['color'] = $value; + } + } + + return $colors; + } + + /** + * Saves checked entries: $replace false merges them into the stored colors (set_global_colors with + * "already sanitized"), $replace true replaces all stored colors (restore). Customizer colors among them go to + * the theme options (Divi does that itself). + * + * @param array> $entries + * @throws DiviLocked When color-write is not released – before anything is written. + * @throws RuntimeException When Divi's sanitizing drops an entry; nothing is written then. + */ + public function save(array $entries, bool $replace): void + { + ReleaseGate::assertFeature(DiviFeature::ColorWrite); + $clean = GlobalData::sanitize_global_colors_data($entries); + $dropped = array_diff(array_keys($entries), array_keys($clean)); + if ($dropped !== []) { + throw new RuntimeException('global colors refused: Divi dropped ' . implode(', ', $dropped)); + } + + GlobalData::set_global_colors($clean, ! $replace); + } +} diff --git a/src/Integrations/Divi/Design/GlobalVariablesAdapter.php b/src/Integrations/Divi/Design/GlobalVariablesAdapter.php new file mode 100644 index 0000000..4b05233 --- /dev/null +++ b/src/Integrations/Divi/Design/GlobalVariablesAdapter.php @@ -0,0 +1,89 @@ + ID => entry as arrays, without allowedActions, without the customizer fonts and the + * group "colors" (both are not stored there), empty groups left out. + * + * @return array>> + */ + public function read(): array + { + $data = []; + foreach (GlobalData::get_global_variables() as $type => $items) { + if ($type === 'colors' || (! is_array($items) && ! is_object($items))) { + continue; + } + foreach ((array) $items as $id => $item) { + // Divi's sanitizing keeps gvid- IDs only; the customizer fonts (--et_global_*_font) are not stored. + if (! str_starts_with((string) $id, 'gvid-') || (! is_array($item) && ! is_object($item))) { + continue; + } + $entry = (array) $item; + unset($entry['allowedActions']); + $data[(string) $type][(string) $id] = $entry; + } + } + + return $data; + } + + /** + * @param array>> $data The whole new inventory (stored form). + * @param array>> $expected The inventory the change was planned on. + * @throws DiviLocked When variable-write is not released – before anything is written. + * @throws RuntimeException When Divi refuses the inventory (stale, missing IDs, invalid entries). + */ + public function save(array $data, array $expected): void + { + ReleaseGate::assertFeature(DiviFeature::VariableWrite); + $refusal = GlobalData::set_global_variables($data, $expected); + if ($refusal instanceof WP_Error) { + throw new RuntimeException('global variables refused: ' . $refusal->get_error_code() . ' ' + . $refusal->get_error_message()); + } + } + + /** Divi's role right "Variables Manager"; fail closed without Divi. */ + public function canManage(): bool + { + return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed('variables_manager'); + } +} diff --git a/src/Integrations/Divi/Release/DiviAdapters.php b/src/Integrations/Divi/Release/DiviAdapters.php index 37f6fb3..5e09753 100644 --- a/src/Integrations/Divi/Release/DiviAdapters.php +++ b/src/Integrations/Divi/Release/DiviAdapters.php @@ -6,6 +6,8 @@ namespace LIT\MCP\Integrations\Divi\Release; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; +use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter; +use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Pages\DiviPages; @@ -27,6 +29,8 @@ final class DiviAdapters DiviThemeBuilder::class, DesignSystemReader::class, DesignSystemWriter::class, + GlobalColorsAdapter::class, + GlobalVariablesAdapter::class, DiviModules::class, DynamicContentMap::class, ]; diff --git a/src/Integrations/Divi/Release/DiviFeature.php b/src/Integrations/Divi/Release/DiviFeature.php index 1762e35..2880001 100644 --- a/src/Integrations/Divi/Release/DiviFeature.php +++ b/src/Integrations/Divi/Release/DiviFeature.php @@ -21,17 +21,20 @@ enum DiviFeature: string case DynamicSources = 'dynamic-sources'; case DesignRead = 'design-read'; case PresetWrite = 'preset-write'; + case ColorWrite = 'color-write'; + case VariableWrite = 'variable-write'; case LibraryRead = 'library-read'; case LibraryWrite = 'library-write'; case Roles = 'roles'; case ThemeBuilderRead = 'tb-read'; case ThemeBuilderWrite = 'tb-write'; - /** Whether the feature changes data (the four *Write cases). */ + /** Whether the feature changes data (the six *Write cases). */ public function isWrite(): bool { return match ($this) { - self::PageWrite, self::PresetWrite, self::LibraryWrite, self::ThemeBuilderWrite => true, + self::PageWrite, self::PresetWrite, self::ColorWrite, self::VariableWrite, self::LibraryWrite, + self::ThemeBuilderWrite => true, default => false, }; } @@ -46,6 +49,8 @@ enum DiviFeature: string self::DynamicSources => 'Dynamische Inhalte', self::DesignRead => 'Designsystem lesen', self::PresetWrite => 'Presets schreiben', + self::ColorWrite => 'Farben schreiben', + self::VariableWrite => 'Variablen schreiben', self::LibraryRead => 'Bibliothek lesen', self::LibraryWrite => 'Bibliothek schreiben', self::Roles => 'Divi-Rollen', diff --git a/src/Integrations/Divi/Release/fingerprints.json b/src/Integrations/Divi/Release/fingerprints.json index 87eaf8d..16da6fa 100644 --- a/src/Integrations/Divi/Release/fingerprints.json +++ b/src/Integrations/Divi/Release/fingerprints.json @@ -6,6 +6,9 @@ "sig:ET\\Builder\\Packages\\Conversion\\Conversion::get_preset_attrs_mapping": "b5c3403666ff92be1dd7bfa6e6589c32f6662b37282f2fd40922f8d50151745d", "sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::get_global_colors": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae", "sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::get_global_variables": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae", + "sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::sanitize_global_colors_data": "94f109150a8d6db57c309de00f4f4e369b4cd4a7e2a0682d5612dddeb65c8861", + "sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::set_global_colors": "d269cc06168a1898ea999e6df332449cf9bc04c17abefbb8f729a05136c836e6", + "sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::set_global_variables": "a2f422943530dba1afb5133459b25341dc67aa29e0be0a8626cbd60619c5a267", "sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::get_data": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae", "sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::get_deleted_preset_ids": "d36bfe7698116a94f7c6bc5ff00e3f66f3cd4267b68b5e61f0a5ec01e051b450", "sig:ET\\Builder\\Packages\\GlobalData\\GlobalPreset::get_group_preset_default_attr": "427ee58858537cf02a9d6dbbf58aafd779f8424c9c840806b5d7af8f25fbbda5", diff --git a/tests/Integration/divi-release-smoke.sh b/tests/Integration/divi-release-smoke.sh index aa0bdea..35dd803 100755 --- a/tests/Integration/divi-release-smoke.sh +++ b/tests/Integration/divi-release-smoke.sh @@ -27,8 +27,8 @@ trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT check_pages() { wp post list --post_type=page --post_status=any --meta_key=_lit_mcp_check_page --format=count; } -# The ids of the eleven features (plan decision D1), sorted. -FEATURE_IDS='["catalog","design-read","dynamic-sources","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write"]' +# The ids of the thirteen features (plan decision D1, plan 4c V2), sorted. +FEATURE_IDS='["catalog","color-write","design-read","dynamic-sources","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write","variable-write"]' # Adds a problem to one feature in the cached state, as if its mirror check had failed. Needs the cache entry of # ReleaseResolver (format 2: ["format" => 2, "key" => stateKey, "problems" => list of Problem::toArray()]); the key @@ -68,7 +68,7 @@ echo "$STATUS" | jq -e --argjson ids "$FEATURE_IDS" ' .components[] | select(.id == "divi") | .features | ([.[].id] | sort) == $ids and all(.state == "released" and .source == "code" and .problems == [])' >/dev/null \ - || fail "all eleven features must be released from code without problems: $STATUS" + || fail "all thirteen features must be released from code without problems: $STATUS" echo "$STATUS" | jq -e ' .components[] | select(.id == "divi") | .features | (.[] | select(.id == "page-write") | .tools | any(. == "lit-divi-insert")) @@ -76,7 +76,7 @@ echo "$STATUS" | jq -e ' and (.[] | select(.id == "preset-write") | .tools | any(startswith("lit/divi-presets-"))) and (.[] | select(.id == "page-read") | .label == "Seiten lesen")' >/dev/null \ || fail "features must name their tools (direct lit-, indirect lit/): $STATUS" -pass "lit-site-status: eleven features released (code), each with its tools" +pass "lit-site-status: thirteen features released (code), each with its tools" # 2. Admin check through the REST route (application password instead of the cookie nonce). # X-Forwarded-Proto: https is required here: without it the request to http://localhost with the @@ -212,8 +212,8 @@ simulate_problem page-write "Smoke: Cache vergiftet" CLI=$(wp --user="$ADMIN_LOGIN" lit-mcp divi-check 2>&1) || fail "wp lit-mcp divi-check must succeed (exit 0): $CLI" echo "$CLI" | grep -q '^Fähigkeiten:' || fail "divi-check must print the matrix: $CLI" echo "$CLI" | grep -q '✓ Seiten schreiben – freigegeben (code)' || fail "divi-check must show page-write released: $CLI" -[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "11" ] || fail "divi-check must show eleven released features: $CLI" +[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "13" ] || fail "divi-check must show thirteen released features: $CLI" [ "$(check_pages)" = "0" ] || fail "the CLI check must delete its page" -pass "wp lit-mcp divi-check prints eleven released features, recomputes the state and leaves no page" +pass "wp lit-mcp divi-check prints thirteen released features, recomputes the state and leaves no page" finish diff --git a/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php b/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php index 4867ef6..0714c59 100644 --- a/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php +++ b/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php @@ -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']); + } } diff --git a/tests/Unit/Integrations/Divi/Design/GlobalColorsAdapterTest.php b/tests/Unit/Integrations/Divi/Design/GlobalColorsAdapterTest.php new file mode 100644 index 0000000..40a57e2 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/GlobalColorsAdapterTest.php @@ -0,0 +1,119 @@ +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); + } +} diff --git a/tests/Unit/Integrations/Divi/Design/GlobalVariablesAdapterTest.php b/tests/Unit/Integrations/Divi/Design/GlobalVariablesAdapterTest.php new file mode 100644 index 0000000..ef5d23a --- /dev/null +++ b/tests/Unit/Integrations/Divi/Design/GlobalVariablesAdapterTest.php @@ -0,0 +1,112 @@ +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); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php b/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php index 2222da6..5033b0f 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php @@ -6,6 +6,8 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; +use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter; +use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Pages\DiviPages; @@ -70,6 +72,16 @@ final class DiviFeatureAssignmentTest extends TestCase 'UserRole::can_current_user_use_visual_builder()' => ['preset-write'], 'Nachbau GlobalPresetController::sync()' => ['preset-write'], ], + GlobalColorsAdapter::class => [ + 'GlobalData::get_global_colors()' => ['color-write'], + 'GlobalData::sanitize_global_colors_data()' => ['color-write'], + 'GlobalData::set_global_colors()' => ['color-write'], + ], + GlobalVariablesAdapter::class => [ + 'GlobalData::get_global_variables()' => ['variable-write'], + 'GlobalData::set_global_variables()' => ['variable-write'], + 'et_pb_is_allowed()' => ['variable-write'], + ], DiviModules::class => [ 'ModuleRegistration::get_all_core_modules_metadata()' => ['catalog'], 'ModuleRegistration::get_default_attrs()' => ['catalog'], @@ -131,7 +143,7 @@ final class DiviFeatureAssignmentTest extends TestCase } self::assertSame(self::EXPECTED, $actual); - self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all eight adapters, in report order'); + self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all ten adapters, in report order'); } public function testReadingAdaptersCarryNoWriteFeature(): void diff --git a/tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php b/tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php index 37e3c7b..f02476a 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; final class DiviFeatureTest extends TestCase { - public function testTheElevenFeaturesAndTheirValues(): void + public function testTheThirteenFeaturesAndTheirValues(): void { self::assertSame( [ @@ -19,6 +19,8 @@ final class DiviFeatureTest extends TestCase 'DynamicSources' => 'dynamic-sources', 'DesignRead' => 'design-read', 'PresetWrite' => 'preset-write', + 'ColorWrite' => 'color-write', + 'VariableWrite' => 'variable-write', 'LibraryRead' => 'library-read', 'LibraryWrite' => 'library-write', 'Roles' => 'roles', @@ -32,14 +34,17 @@ final class DiviFeatureTest extends TestCase ); } - public function testOnlyTheFourWriteFeaturesWrite(): void + public function testOnlyTheSixWriteFeaturesWrite(): void { $writing = array_values(array_map( static fn (DiviFeature $feature): string => $feature->value, array_filter(DiviFeature::cases(), static fn (DiviFeature $feature): bool => $feature->isWrite()) )); - self::assertSame(['page-write', 'preset-write', 'library-write', 'tb-write'], $writing); + self::assertSame( + ['page-write', 'preset-write', 'color-write', 'variable-write', 'library-write', 'tb-write'], + $writing + ); } public function testEveryFeatureHasAUniqueGermanLabel(): void @@ -49,6 +54,8 @@ final class DiviFeatureTest extends TestCase self::assertCount(count(DiviFeature::cases()), array_unique($labels)); self::assertSame('Seiten schreiben', DiviFeature::PageWrite->label()); self::assertSame('Theme Builder lesen', DiviFeature::ThemeBuilderRead->label()); + self::assertSame('Farben schreiben', DiviFeature::ColorWrite->label()); + self::assertSame('Variablen schreiben', DiviFeature::VariableWrite->label()); foreach ($labels as $label) { self::assertNotSame('', trim($label)); }