diff --git a/src/Integrations/Divi/Design/DesignSystemReader.php b/src/Integrations/Divi/Design/DesignSystemReader.php index 87a77d9..e722131 100644 --- a/src/Integrations/Divi/Design/DesignSystemReader.php +++ b/src/Integrations/Divi/Design/DesignSystemReader.php @@ -8,6 +8,7 @@ use ET\Builder\Packages\GlobalData\GlobalData; use ET\Builder\Packages\GlobalData\GlobalPreset; use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\DiviAdapter; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\Manifest; defined('ABSPATH') || exit; @@ -34,7 +35,8 @@ class DesignSystemReader implements DiviAdapter new Call(GlobalData::class . '::get_global_variables', 0), new Call(GlobalPreset::class . '::get_data', 0), ], - keys: [self::OPTIONS] + keys: [self::OPTIONS], + features: [DiviFeature::DesignRead, DiviFeature::PageRead] ); } diff --git a/src/Integrations/Divi/Design/DesignSystemWriter.php b/src/Integrations/Divi/Design/DesignSystemWriter.php index 80d7026..743965d 100644 --- a/src/Integrations/Divi/Design/DesignSystemWriter.php +++ b/src/Integrations/Divi/Design/DesignSystemWriter.php @@ -10,6 +10,7 @@ use ET\Builder\Packages\GlobalData\GlobalPreset; use ET\Builder\Packages\ModuleLibrary\ModuleRegistration; use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\DiviAdapter; +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\Mirror; @@ -44,7 +45,8 @@ class DesignSystemWriter implements DiviAdapter return new Manifest( Manifest::DESIGN, calls: [ - new Call(GlobalPreset::class . '::get_data', 0), + // Read by the preset tools as well as by the design system tool. + new Call(GlobalPreset::class . '::get_data', 0, [DiviFeature::DesignRead, DiviFeature::PresetWrite]), new Call(GlobalPreset::class . '::prepare_data', 1), new Call(GlobalPreset::class . '::validate_preset_count', 3), new Call(GlobalPreset::class . '::get_deleted_preset_ids', 3), @@ -52,8 +54,17 @@ class DesignSystemWriter implements DiviAdapter new Call(GlobalPreset::class . '::save_data', 1), new Call(Conversion::class . '::get_preset_attrs_mapping', 1), new Call(Conversion::class . '::get_preset_attrs', 3), - new Call(GlobalPreset::class . '::get_group_preset_default_attr', 1), - new Call(ModuleRegistration::class . '::get_module_settings', 1), + // groupsOf(): also used by the composer for group presets (insert/update/assign), so Catalog too. + new Call( + GlobalPreset::class . '::get_group_preset_default_attr', + 1, + [DiviFeature::Catalog, DiviFeature::PresetWrite] + ), + new Call( + ModuleRegistration::class . '::get_module_settings', + 1, + [DiviFeature::Catalog, DiviFeature::PresetWrite] + ), new Call(UserRole::class . '::can_current_user_use_visual_builder', 0), ], mirrors: [ @@ -65,7 +76,8 @@ class DesignSystemWriter implements DiviAdapter 'Speicherablauf der Presets (prepare_data, validate_preset_count, Bereinigung der Divi-4-Option ' . 'beim Löschen, save_data)' ), - ] + ], + features: [DiviFeature::PresetWrite] ); } diff --git a/src/Integrations/Divi/Library/DiviLibraryStore.php b/src/Integrations/Divi/Library/DiviLibraryStore.php index c2abfa8..18feaa6 100644 --- a/src/Integrations/Divi/Library/DiviLibraryStore.php +++ b/src/Integrations/Divi/Library/DiviLibraryStore.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Integrations\Divi\Library; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\DiviAdapter; +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\Mirror; @@ -55,7 +56,14 @@ class DiviLibraryStore implements DiviAdapter { return new Manifest( Manifest::LIBRARY, - calls: [new Call(self::GLOBAL_LAYOUT . '::is_global_layout_template', 1)], + calls: [ + // isGlobal() serves listing and describing as well as the global edits. + new Call( + self::GLOBAL_LAYOUT . '::is_global_layout_template', + 1, + [DiviFeature::LibraryRead, DiviFeature::LibraryWrite] + ), + ], postTypes: [self::POST_TYPE], taxonomies: [...self::TAXONOMIES, 'layout_category', 'layout_tag'], keys: [self::MODULE_TYPE, self::EXCLUDED_OPTIONS, self::BUILT_FOR, self::TEMPLATE_TYPE], @@ -68,7 +76,8 @@ class DiviLibraryStore implements DiviAdapter 'Terme und Meta neuer Bibliothekseinträge (scope, layout_type, module_width, _et_pb_module_type, ' . '_et_pb_excluded_global_options, _et_pb_built_for_post_type, _et_pb_template_type)' ), - ] + ], + features: [DiviFeature::LibraryWrite] ); } diff --git a/src/Integrations/Divi/Library/DiviRoles.php b/src/Integrations/Divi/Library/DiviRoles.php index 97090a0..e2c3e22 100644 --- a/src/Integrations/Divi/Library/DiviRoles.php +++ b/src/Integrations/Divi/Library/DiviRoles.php @@ -8,6 +8,7 @@ use Closure; use LIT\MCP\Abilities\Errors; use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\DiviAdapter; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\Manifest; use WP_Error; @@ -33,7 +34,11 @@ class DiviRoles implements DiviAdapter public static function manifest(): Manifest { - return new Manifest(Manifest::LIBRARY, calls: [new Call('et_pb_is_allowed', 1)]); + return new Manifest( + Manifest::LIBRARY, + calls: [new Call('et_pb_is_allowed', 1)], + features: [DiviFeature::Roles] + ); } /** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */ diff --git a/src/Integrations/Divi/Pages/DiviPages.php b/src/Integrations/Divi/Pages/DiviPages.php index fa35d4a..6e997f9 100644 --- a/src/Integrations/Divi/Pages/DiviPages.php +++ b/src/Integrations/Divi/Pages/DiviPages.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages; use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\DiviAdapter; +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\Mirror; @@ -36,7 +37,12 @@ final class DiviPages implements DiviAdapter return new Manifest( Manifest::PAGES, calls: [ - new Call('ET_Core_PageResource::remove_static_resources', 3), + // Also called by DiviThemeBuilder::clearSiteCaches() – a break locks both features. + new Call( + 'ET_Core_PageResource::remove_static_resources', + 3, + [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite] + ), new Call('et_builder_get_public_post_types', 0), ], keys: ['_et_pb_use_divi_5', '_et_pb_use_builder', '_et_pb_show_page_creation', 'et_fb'], @@ -48,7 +54,8 @@ final class DiviPages implements DiviAdapter 'SyncToServerController', 'Builder-Kennzeichen einer Seite (_et_pb_use_divi_5, _et_pb_use_builder, _et_pb_show_page_creation)' ), - ] + ], + features: [DiviFeature::PageWrite] ); } diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php index 704f291..19bce45 100644 --- a/src/Integrations/Divi/Pages/DynamicContentMap.php +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\Compose\Placeholders; use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules; use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\DiviAdapter; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Log; use Throwable; @@ -46,7 +47,11 @@ class DynamicContentMap implements DiviAdapter public static function manifest(): Manifest { - return new Manifest(Manifest::CATALOG, calls: [new Call(self::REGISTRY . '::get_options', 2)]); + return new Manifest( + Manifest::CATALOG, + calls: [new Call(self::REGISTRY . '::get_options', 2)], + features: [DiviFeature::DynamicSources] + ); } /** @return array>|null Null = use DynamicContentRules' default map. */ diff --git a/src/Integrations/Divi/Release/Call.php b/src/Integrations/Divi/Release/Call.php index 1f558c6..2b714ac 100644 --- a/src/Integrations/Divi/Release/Call.php +++ b/src/Integrations/Divi/Release/Call.php @@ -10,11 +10,17 @@ defined('ABSPATH') || exit; * One Divi function or static method an adapter calls (spec §4.1), with the number of arguments the plugin passes. * $args null: only its existence matters (a function Divi itself needs, e.g. et_() inside store_template). */ -final class Call +final class Call implements Requirement { - /** @param string $symbol "function_name" or "Fully\Qualified\Class::method". */ - public function __construct(public readonly string $symbol, public readonly ?int $args = null) - { + /** + * @param string $symbol "function_name" or "Fully\Qualified\Class::method". + * @param list $features [] = the default of the manifest. + */ + public function __construct( + public readonly string $symbol, + public readonly ?int $args = null, + private readonly array $features = [] + ) { } public function className(): ?string @@ -27,6 +33,11 @@ final class Call return str_contains($this->symbol, '::') ? explode('::', $this->symbol, 2)[1] : $this->symbol; } + public function features(): array + { + return $this->features; + } + public function label(): string { $class = $this->className(); diff --git a/src/Integrations/Divi/Release/ConstantRequirement.php b/src/Integrations/Divi/Release/ConstantRequirement.php new file mode 100644 index 0000000..86a4c23 --- /dev/null +++ b/src/Integrations/Divi/Release/ConstantRequirement.php @@ -0,0 +1,26 @@ + $features [] = the default of the manifest. */ + public function __construct(public readonly string $name, private readonly array $features = []) + { + } + + public function label(): string + { + return 'Konstante ' . $this->name; + } + + public function features(): array + { + return $this->features; + } +} diff --git a/src/Integrations/Divi/Release/DiviFeature.php b/src/Integrations/Divi/Release/DiviFeature.php new file mode 100644 index 0000000..1762e35 --- /dev/null +++ b/src/Integrations/Divi/Release/DiviFeature.php @@ -0,0 +1,56 @@ + true, + default => false, + }; + } + + /** Name for messages, the status tool and the admin tab (German). */ + public function label(): string + { + return match ($this) { + self::PageRead => 'Seiten lesen', + self::PageWrite => 'Seiten schreiben', + self::Catalog => 'Modulkatalog', + self::DynamicSources => 'Dynamische Inhalte', + self::DesignRead => 'Designsystem lesen', + self::PresetWrite => 'Presets schreiben', + self::LibraryRead => 'Bibliothek lesen', + self::LibraryWrite => 'Bibliothek schreiben', + self::Roles => 'Divi-Rollen', + self::ThemeBuilderRead => 'Theme Builder lesen', + self::ThemeBuilderWrite => 'Theme Builder schreiben', + }; + } +} diff --git a/src/Integrations/Divi/Release/Manifest.php b/src/Integrations/Divi/Release/Manifest.php index 7819a0e..e84cd9c 100644 --- a/src/Integrations/Divi/Release/Manifest.php +++ b/src/Integrations/Divi/Release/Manifest.php @@ -18,27 +18,56 @@ final class Manifest public const DESIGN = 'Designsystem'; public const CATALOG = 'Katalog'; + /** @var list */ + public readonly array $constants; + /** - * @param string $area One of the constants above (the five areas of spec §3). - * @param list $calls Divi functions and static methods the adapter calls. - * @param list $constants Divi constants it reads. - * @param list $postTypes Divi post types it needs registered. - * @param list $taxonomies Divi taxonomies it needs registered. - * @param list $blockTypes Divi block types it needs (registered or in Divi's module metadata). - * @param list $keys Other Divi names it uses as strings: meta keys, options, internal post - * types that need no registration check, query arguments. - * @param list $mirrors Divi functions whose storage logic it repeats. + * @param string $area One of the constants above (the five areas of spec §3). + * @param list $calls Divi functions and static methods the adapter calls. + * @param list $constants Divi constants it reads; a plain name stands for a + * ConstantRequirement without features of its own. + * @param list $postTypes Divi post types it needs registered. + * @param list $taxonomies Divi taxonomies it needs registered. + * @param list $blockTypes Divi block types it needs (registered or in Divi's + * module metadata). + * @param list $keys Other Divi names it uses as strings: meta keys, options, + * internal post types that need no registration check, + * query arguments. + * @param list $mirrors Divi functions whose storage logic it repeats. + * @param list $features The features of every call, mirror and constant that names + * none itself (featuresOf()). */ public function __construct( public readonly string $area, public readonly array $calls = [], - public readonly array $constants = [], + array $constants = [], public readonly array $postTypes = [], public readonly array $taxonomies = [], public readonly array $blockTypes = [], public readonly array $keys = [], - public readonly array $mirrors = [] + public readonly array $mirrors = [], + public readonly array $features = [] ) { + $this->constants = array_map( + static fn (string|ConstantRequirement $constant): ConstantRequirement => is_string($constant) + ? new ConstantRequirement($constant) + : $constant, + array_values($constants) + ); + } + + /** @return list Calls, constants and mirrors – everything the release gate checks as code. */ + public function requirements(): array + { + return [...$this->calls, ...$this->constants, ...$this->mirrors]; + } + + /** @return list The features $requirement names itself, else this manifest's default. */ + public function featuresOf(Requirement $requirement): array + { + $own = $requirement->features(); + + return $own !== [] ? $own : $this->features; } /** @@ -51,11 +80,12 @@ final class Manifest foreach ($this->calls as $call) { $names[] = $call->className() ?? $call->member(); } + $constants = array_map(static fn (ConstantRequirement $constant): string => $constant->name, $this->constants); $mirrored = array_map(static fn (Mirror $mirror): string => $mirror->function, $this->mirrors); return array_values(array_unique(array_merge( $names, - $this->constants, + $constants, $this->postTypes, $this->taxonomies, $this->keys, diff --git a/src/Integrations/Divi/Release/ManifestCheck.php b/src/Integrations/Divi/Release/ManifestCheck.php index 352e8da..f765077 100644 --- a/src/Integrations/Divi/Release/ManifestCheck.php +++ b/src/Integrations/Divi/Release/ManifestCheck.php @@ -57,7 +57,8 @@ class ManifestCheck $checks[$manifest->area . ': ' . $call->label()] = static fn (): bool => self::accepts($call); } foreach ($manifest->constants as $constant) { - $checks[$manifest->area . ': Konstante ' . $constant] = static fn (): bool => defined($constant); + $checks[$manifest->area . ': ' . $constant->label()] = + static fn (): bool => defined($constant->name); } } diff --git a/src/Integrations/Divi/Release/Mirror.php b/src/Integrations/Divi/Release/Mirror.php index 95bc431..e6f42bb 100644 --- a/src/Integrations/Divi/Release/Mirror.php +++ b/src/Integrations/Divi/Release/Mirror.php @@ -10,25 +10,33 @@ defined('ABSPATH') || exit; * A Divi function whose storage logic an adapter repeats instead of calling it (spec §4.1 "mirrors"). Its * fingerprint (SourceFingerprint) must match the one in fingerprints.json, else Divi changed what we copy. */ -final class Mirror +final class Mirror implements Requirement { /** - * @param string $id Key in fingerprints.json and name of tests/Fixtures/divi/reference/.txt. - * @param string $file Divi file, relative to the Divi theme directory. - * @param string $function Function or method name. - * @param string|null $class Class of a method – the short name is enough (the file fixes where it - * lives, and a short name is no Divi name for DiviBoundaryTest); null for a function. - * @param string $what What the plugin repeats (German, for reports). + * @param string $id Key in fingerprints.json and name of tests/Fixtures/divi/reference/.txt. + * @param string $file Divi file, relative to the Divi theme directory. + * @param string $function Function or method name. + * @param string|null $class Class of a method – the short name is enough (the file fixes where it + * lives, and a short name is no Divi name for DiviBoundaryTest); null for a + * function. + * @param string $what What the plugin repeats (German, for reports). + * @param list $features [] = the default of the manifest. */ public function __construct( public readonly string $id, public readonly string $file, public readonly string $function, public readonly ?string $class, - public readonly string $what + public readonly string $what, + private readonly array $features = [] ) { } + public function features(): array + { + return $this->features; + } + public function label(): string { $class = $this->class === null ? '' : SourceFingerprint::shortName($this->class) . '::'; diff --git a/src/Integrations/Divi/Release/Requirement.php b/src/Integrations/Divi/Release/Requirement.php new file mode 100644 index 0000000..b119701 --- /dev/null +++ b/src/Integrations/Divi/Release/Requirement.php @@ -0,0 +1,22 @@ + + */ + public function features(): array; +} diff --git a/src/Integrations/Divi/Schema/DiviModules.php b/src/Integrations/Divi/Schema/DiviModules.php index 1339ba1..feaeeed 100644 --- a/src/Integrations/Divi/Schema/DiviModules.php +++ b/src/Integrations/Divi/Schema/DiviModules.php @@ -9,6 +9,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\DiviAdapter; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; @@ -46,7 +47,8 @@ class DiviModules implements DiviAdapter ], constants: ['ET_BUILDER_PRODUCT_VERSION'], blockTypes: [GlobalInstance::BLOCK], - keys: ['et_pb_canvas', '_et_pb_speculation'] + keys: ['et_pb_canvas', '_et_pb_speculation'], + features: [DiviFeature::Catalog] ); } diff --git a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php index 29dbd55..d573431 100644 --- a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php +++ b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\ThemeBuilder; use LIT\MCP\Integrations\Divi\Release\Call; use LIT\MCP\Integrations\Divi\Release\DiviAdapter; +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\Mirror; @@ -84,16 +85,31 @@ class DiviThemeBuilder implements DiviAdapter return new Manifest( Manifest::THEME_BUILDER, calls: [ - new Call('et_theme_builder_get_theme_builder_post_id', 2), + // liveId(): reads the live post, or creates it like Divi's first save. + new Call( + 'et_theme_builder_get_theme_builder_post_id', + 2, + [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite] + ), new Call('et_theme_builder_get_theme_builder_template_ids', 2), new Call('et_theme_builder_get_template', 1), - new Call('et_theme_builder_insert_layout', 1), - new Call('et_theme_builder_store_template', 3), + new Call('et_theme_builder_insert_layout', 1, [DiviFeature::ThemeBuilderWrite]), + new Call('et_theme_builder_store_template', 3, [DiviFeature::ThemeBuilderWrite]), new Call('et_theme_builder_get_flat_template_settings_options', 0), - new Call('et_theme_builder_clear_wp_cache', 1), + // clearSiteCaches() also runs after every page save with a site-wide scope (DiviSaver). + new Call( + 'et_theme_builder_clear_wp_cache', + 1, + [DiviFeature::ThemeBuilderWrite, DiviFeature::PageWrite] + ), // store_template needs Divi's et_() helper (E15). - new Call('et_'), - new Call('ET_Core_PageResource::remove_static_resources', 3), + new Call('et_', null, [DiviFeature::ThemeBuilderWrite]), + // Also called by DiviPages::clearPageCss() – a break locks both features. + new Call( + 'ET_Core_PageResource::remove_static_resources', + 3, + [DiviFeature::ThemeBuilderWrite, DiviFeature::PageWrite] + ), ], postTypes: self::postTypes(), keys: [ @@ -106,23 +122,27 @@ class DiviThemeBuilder implements DiviAdapter 'et_theme_builder_api_save', null, 'Vorlagenliste des Live-Theme-Builders (_et_template mit Sicherung et_tb_templates_backup_) und ' - . '„ungenutzt“-Markierung' + . '„ungenutzt“-Markierung', + [DiviFeature::ThemeBuilderWrite] ), new Mirror( 'tb-store-template', $dir . 'theme-builder.php', 'et_theme_builder_store_template', null, - 'Bedingungen (_et_use_on, _et_exclude_from) und Aktiv-Schalter (_et_enabled) einer Vorlage' + 'Bedingungen (_et_use_on, _et_exclude_from) und Aktiv-Schalter (_et_enabled) einer Vorlage', + [DiviFeature::ThemeBuilderWrite] ), new Mirror( 'tb-load-setting-options', $dir . 'theme-builder.php', 'et_theme_builder_load_template_setting_options', null, - 'Zerlegen der Bedingungs-IDs (TemplateConditions)' + 'Zerlegen der Bedingungs-IDs (TemplateConditions)', + [DiviFeature::ThemeBuilderRead, DiviFeature::ThemeBuilderWrite] ), - ] + ], + features: [DiviFeature::ThemeBuilderRead] ); } diff --git a/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php b/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php new file mode 100644 index 0000000..2222da6 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php @@ -0,0 +1,163 @@ + requirement ("Nachbau " before a mirror's label) => features (sorted values). */ + private const EXPECTED = [ + DiviPages::class => [ + 'ET_Core_PageResource::remove_static_resources()' => ['page-write', 'tb-write'], + 'et_builder_get_public_post_types()' => ['page-write'], + 'Nachbau SyncToServerController::update()' => ['page-write'], + ], + DiviLibraryStore::class => [ + 'GlobalLayout::is_global_layout_template()' => ['library-read', 'library-write'], + 'Nachbau et_pb_submit_layout()' => ['library-write'], + ], + DiviRoles::class => [ + 'et_pb_is_allowed()' => ['roles'], + ], + DiviThemeBuilder::class => [ + 'et_theme_builder_get_theme_builder_post_id()' => ['tb-read', 'tb-write'], + 'et_theme_builder_get_theme_builder_template_ids()' => ['tb-read'], + 'et_theme_builder_get_template()' => ['tb-read'], + 'et_theme_builder_insert_layout()' => ['tb-write'], + 'et_theme_builder_store_template()' => ['tb-write'], + 'et_theme_builder_get_flat_template_settings_options()' => ['tb-read'], + 'et_theme_builder_clear_wp_cache()' => ['page-write', 'tb-write'], + 'et_()' => ['tb-write'], + 'ET_Core_PageResource::remove_static_resources()' => ['page-write', 'tb-write'], + 'Nachbau et_theme_builder_api_save()' => ['tb-write'], + 'Nachbau et_theme_builder_store_template()' => ['tb-write'], + 'Nachbau et_theme_builder_load_template_setting_options()' => ['tb-read', 'tb-write'], + ], + DesignSystemReader::class => [ + 'GlobalData::get_global_colors()' => ['design-read', 'page-read'], + 'GlobalData::get_global_variables()' => ['design-read', 'page-read'], + 'GlobalPreset::get_data()' => ['design-read', 'page-read'], + ], + DesignSystemWriter::class => [ + 'GlobalPreset::get_data()' => ['design-read', 'preset-write'], + 'GlobalPreset::prepare_data()' => ['preset-write'], + 'GlobalPreset::validate_preset_count()' => ['preset-write'], + 'GlobalPreset::get_deleted_preset_ids()' => ['preset-write'], + 'GlobalPreset::remove_preset_from_legacy_option()' => ['preset-write'], + 'GlobalPreset::save_data()' => ['preset-write'], + 'Conversion::get_preset_attrs_mapping()' => ['preset-write'], + 'Conversion::get_preset_attrs()' => ['preset-write'], + 'GlobalPreset::get_group_preset_default_attr()' => ['catalog', 'preset-write'], + 'ModuleRegistration::get_module_settings()' => ['catalog', 'preset-write'], + 'UserRole::can_current_user_use_visual_builder()' => ['preset-write'], + 'Nachbau GlobalPresetController::sync()' => ['preset-write'], + ], + DiviModules::class => [ + 'ModuleRegistration::get_all_core_modules_metadata()' => ['catalog'], + 'ModuleRegistration::get_default_attrs()' => ['catalog'], + 'et_builder_enabled_for_post_type()' => ['catalog'], + 'Konstante ET_BUILDER_PRODUCT_VERSION' => ['catalog'], + ], + DynamicContentMap::class => [ + 'DynamicContentOptions::get_options()' => ['dynamic-sources'], + ], + ]; + + public function testEveryRequirementOfEveryAdapterServesAtLeastOneFeature(): void + { + $bare = []; + foreach (DiviAdapters::manifests() as $class => $manifest) { + foreach ($manifest->requirements() as $requirement) { + $features = $manifest->featuresOf($requirement); + if ($features === [] || array_unique($features, SORT_REGULAR) !== $features) { + $bare[] = $class . ': ' . $requirement->label(); + } + } + } + + self::assertSame([], $bare, "Anforderungen ohne (eindeutige) Fähigkeit:\n" . implode("\n", $bare)); + } + + public function testEveryFeatureIsServedByAtLeastOneRequirement(): void + { + $served = []; + foreach (DiviAdapters::manifests() as $manifest) { + foreach ($manifest->requirements() as $requirement) { + foreach ($manifest->featuresOf($requirement) as $feature) { + $served[$feature->value] = true; + } + } + } + + $missing = array_values(array_diff( + array_map(static fn (DiviFeature $feature): string => $feature->value, DiviFeature::cases()), + array_keys($served) + )); + + self::assertSame([], $missing, 'Fähigkeiten ohne Anforderung in einem Manifest'); + } + + public function testTheAssignmentIsThePinnedTable(): void + { + $actual = []; + foreach (DiviAdapters::manifests() as $class => $manifest) { + foreach ($manifest->requirements() as $requirement) { + $values = array_map( + static fn (DiviFeature $feature): string => $feature->value, + $manifest->featuresOf($requirement) + ); + sort($values); + $name = ($requirement instanceof Mirror ? 'Nachbau ' : '') . $requirement->label(); + $actual[$class][$name] = $values; + } + } + + self::assertSame(self::EXPECTED, $actual); + self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all eight adapters, in report order'); + } + + public function testReadingAdaptersCarryNoWriteFeature(): void + { + $readingOnly = [DesignSystemReader::class, DiviRoles::class, DiviModules::class, DynamicContentMap::class]; + + foreach ($readingOnly as $class) { + $manifest = DiviAdapters::manifests()[$class]; + foreach ($manifest->requirements() as $requirement) { + foreach ($manifest->featuresOf($requirement) as $feature) { + self::assertFalse($feature->isWrite(), $class . ': ' . $requirement->label()); + } + } + } + } + + public function testEveryMirrorServesAWriteFeature(): void + { + foreach (DiviAdapters::manifests() as $class => $manifest) { + foreach ($manifest->mirrors as $mirror) { + $writes = array_filter( + $manifest->featuresOf($mirror), + static fn (DiviFeature $feature): bool => $feature->isWrite() + ); + self::assertNotSame([], $writes, $class . ': Nachbau ' . $mirror->id . ' dient keinem Schreib-Feature'); + } + } + } +} diff --git a/tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php b/tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php new file mode 100644 index 0000000..37e3c7b --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php @@ -0,0 +1,62 @@ + 'page-read', + 'PageWrite' => 'page-write', + 'Catalog' => 'catalog', + 'DynamicSources' => 'dynamic-sources', + 'DesignRead' => 'design-read', + 'PresetWrite' => 'preset-write', + 'LibraryRead' => 'library-read', + 'LibraryWrite' => 'library-write', + 'Roles' => 'roles', + 'ThemeBuilderRead' => 'tb-read', + 'ThemeBuilderWrite' => 'tb-write', + ], + array_column(array_map( + static fn (DiviFeature $feature): array => [$feature->name, $feature->value], + DiviFeature::cases() + ), 1, 0) + ); + } + + public function testOnlyTheFourWriteFeaturesWrite(): 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); + } + + public function testEveryFeatureHasAUniqueGermanLabel(): void + { + $labels = array_map(static fn (DiviFeature $feature): string => $feature->label(), DiviFeature::cases()); + + self::assertCount(count(DiviFeature::cases()), array_unique($labels)); + self::assertSame('Seiten schreiben', DiviFeature::PageWrite->label()); + self::assertSame('Theme Builder lesen', DiviFeature::ThemeBuilderRead->label()); + foreach ($labels as $label) { + self::assertNotSame('', trim($label)); + } + } + + public function testValuesResolveBackToTheFeature(): void + { + self::assertSame(DiviFeature::ThemeBuilderWrite, DiviFeature::from('tb-write')); + self::assertNull(DiviFeature::tryFrom('page')); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/ManifestTest.php b/tests/Unit/Integrations/Divi/Release/ManifestTest.php index 49807fb..8742180 100644 --- a/tests/Unit/Integrations/Divi/Release/ManifestTest.php +++ b/tests/Unit/Integrations/Divi/Release/ManifestTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; use LIT\MCP\Integrations\Divi\Release\Call; +use LIT\MCP\Integrations\Divi\Release\ConstantRequirement; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Integrations\Divi\Release\Mirror; use PHPUnit\Framework\TestCase; @@ -53,4 +55,61 @@ final class ManifestTest extends TestCase ); self::assertSame('Bibliothek', $manifest->area); } + + public function testPlainConstantNamesBecomeRequirementsWithALabel(): void + { + $manifest = new Manifest( + Manifest::CATALOG, + constants: ['ET_BUILDER_PRODUCT_VERSION', new ConstantRequirement('ET_OTHER', [DiviFeature::Roles])] + ); + + self::assertSame( + ['ET_BUILDER_PRODUCT_VERSION', 'ET_OTHER'], + array_map(static fn (ConstantRequirement $constant): string => $constant->name, $manifest->constants) + ); + self::assertSame('Konstante ET_OTHER', $manifest->constants[1]->label()); + self::assertSame([], $manifest->constants[0]->features()); + self::assertSame([DiviFeature::Roles], $manifest->constants[1]->features()); + self::assertSame(['ET_BUILDER_PRODUCT_VERSION', 'ET_OTHER'], $manifest->names()); + } + + public function testRequirementsAreCallsConstantsAndMirrorsInThatOrder(): void + { + $call = new Call('et_pb_is_allowed', 1); + $mirror = new Mirror('m', 'a.php', 'a', null, 'Terme'); + $manifest = new Manifest(Manifest::LIBRARY, calls: [$call], constants: ['ET_X'], mirrors: [$mirror]); + + $requirements = $manifest->requirements(); + + self::assertCount(3, $requirements); + self::assertSame($call, $requirements[0]); + self::assertSame('Konstante ET_X', $requirements[1]->label()); + self::assertSame($mirror, $requirements[2]); + } + + public function testFeaturesOfPrefersTheOwnFeaturesOverTheManifestDefault(): void + { + $own = new Call('et_a', null, [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite]); + $inherited = new Call('et_b'); + $mirror = new Mirror('m', 'a.php', 'a', null, 'x', [DiviFeature::LibraryWrite]); + $manifest = new Manifest( + Manifest::PAGES, + calls: [$own, $inherited], + constants: ['ET_X'], + mirrors: [$mirror], + features: [DiviFeature::PageRead] + ); + + self::assertSame([DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite], $manifest->featuresOf($own)); + self::assertSame([DiviFeature::PageRead], $manifest->featuresOf($inherited)); + self::assertSame([DiviFeature::PageRead], $manifest->featuresOf($manifest->constants[0])); + self::assertSame([DiviFeature::LibraryWrite], $manifest->featuresOf($mirror)); + } + + public function testWithoutAnyFeaturesAnEntryHasNone(): void + { + $call = new Call('et_a'); + + self::assertSame([], (new Manifest(Manifest::PAGES, calls: [$call]))->featuresOf($call)); + } } diff --git a/tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php b/tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php index 0fd1d5b..a0501a7 100644 --- a/tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php +++ b/tests/Unit/Integrations/Divi/Schema/DiviModulesTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Schema; use Brain\Monkey\Functions; use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Release\Call; +use LIT\MCP\Integrations\Divi\Release\ConstantRequirement; use LIT\MCP\Integrations\Divi\Release\DiviAdapters; use LIT\MCP\Integrations\Divi\Release\Manifest; use LIT\MCP\Integrations\Divi\Schema\DiviModules; @@ -35,7 +36,10 @@ final class DiviModulesTest extends TestCase array_map(static fn (Call $call): string => $call->label(), $manifest->calls) ); self::assertSame(['divi/global-layout'], $manifest->blockTypes); - self::assertSame(['ET_BUILDER_PRODUCT_VERSION'], $manifest->constants); + self::assertSame( + ['ET_BUILDER_PRODUCT_VERSION'], + array_map(static fn (ConstantRequirement $constant): string => $constant->name, $manifest->constants) + ); } public function testInternalPostTypesAreDivisOwn(): void