From e387d8d29e895e8bd1e9d37a05d636cef0ff0806 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 18 Oct 2026 22:10:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Divi-Adapter=20sperren=20je=20F=C3=A4hi?= =?UTF-8?q?gkeit=20=E2=80=93=20ReleaseGate::feature/assertFeature(s),=20Di?= =?UTF-8?q?viLocked=20und=20Ablehnung=20nennen=20die=20betroffenen=20F?= =?UTF-8?q?=C3=A4higkeiten=20(D6),=20DiviSaver=20pr=C3=BCft=20Seiten=20sch?= =?UTF-8?q?reiben=20und=20die=20F=C3=A4higkeit=20des=20Speicherbereichs=20?= =?UTF-8?q?(D5),=20DesignArea::feature;=20Test-Standard=20alle=20F=C3=A4hi?= =?UTF-8?q?gkeiten=20freigegeben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/Integrations/Divi/Design/DesignArea.php | 5 + src/Integrations/Divi/Design/DesignChange.php | 2 +- .../Divi/Design/DesignSystemWriter.php | 4 +- src/Integrations/Divi/Design/PresetArea.php | 7 ++ .../Divi/Library/DiviLibraryStore.php | 4 +- src/Integrations/Divi/Pages/DiviPages.php | 4 +- src/Integrations/Divi/Pages/DiviSaver.php | 8 +- src/Integrations/Divi/Pages/SaveScope.php | 12 ++ src/Integrations/Divi/Release/DiviLocked.php | 17 ++- src/Integrations/Divi/Release/ReleaseGate.php | 46 +++++-- .../Divi/Release/ReleaseStatus.php | 75 +++++++++++ .../Divi/ThemeBuilder/DiviThemeBuilder.php | 32 ++--- .../Divi/Design/DesignChangeTest.php | 42 +++++++ .../Divi/Design/PresetAreaTest.php | 2 + .../Integrations/Divi/Pages/SaveScopeTest.php | 13 ++ .../Divi/Release/DiviLockedTest.php | 37 ++++++ .../Divi/Release/ReleaseGateTest.php | 79 +++++++++++- .../Divi/Release/ReleaseGuardsTest.php | 119 ++++++++++++++---- .../Divi/Release/ReleaseStatusTest.php | 75 +++++++++++ tests/Unit/TestCase.php | 7 +- 20 files changed, 525 insertions(+), 65 deletions(-) create mode 100644 tests/Unit/Integrations/Divi/Release/DiviLockedTest.php diff --git a/src/Integrations/Divi/Design/DesignArea.php b/src/Integrations/Divi/Design/DesignArea.php index 0a363cd..d926e0e 100644 --- a/src/Integrations/Divi/Design/DesignArea.php +++ b/src/Integrations/Divi/Design/DesignArea.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Design; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; + defined('ABSPATH') || exit; /** @@ -20,6 +22,9 @@ interface DesignArea /** German name for messages, e.g. "Presets". */ public function label(): string; + /** The Divi feature a write of this area needs (DesignChange asks ReleaseGate::assertFeature() for it). */ + public function feature(): DiviFeature; + /** @return array */ public function raw(): array; diff --git a/src/Integrations/Divi/Design/DesignChange.php b/src/Integrations/Divi/Design/DesignChange.php index c9c3eb0..047700a 100644 --- a/src/Integrations/Divi/Design/DesignChange.php +++ b/src/Integrations/Divi/Design/DesignChange.php @@ -159,7 +159,7 @@ final class DesignChange return $blocked; } - ReleaseGate::assertWritable(); + ReleaseGate::assertFeature($area->feature()); try { $snapshotId = $this->snapshots->save($area->name(), $current, $ability); } catch (SnapshotTooLarge $e) { diff --git a/src/Integrations/Divi/Design/DesignSystemWriter.php b/src/Integrations/Divi/Design/DesignSystemWriter.php index 743965d..a3f8081 100644 --- a/src/Integrations/Divi/Design/DesignSystemWriter.php +++ b/src/Integrations/Divi/Design/DesignSystemWriter.php @@ -94,12 +94,12 @@ class DesignSystemWriter implements DiviAdapter * @param bool $mayRemove Deleting or restoring: fewer presets are allowed and removed ones * leave Divi's legacy option (like a DELETE_* sync). * @return array The inventory Divi stored. - * @throws DiviLocked + * @throws DiviLocked When preset-write is not released – before anything is written. * @throws RuntimeException When Divi refuses the inventory. */ public function savePresets(array $rest, bool $mayRemove): array { - ReleaseGate::assertWritable(); + ReleaseGate::assertFeature(DiviFeature::PresetWrite); $prepared = GlobalPreset::prepare_data($rest); $current = GlobalPreset::get_data(); $refusal = GlobalPreset::validate_preset_count( diff --git a/src/Integrations/Divi/Design/PresetArea.php b/src/Integrations/Divi/Design/PresetArea.php index 7b15639..9ddeed6 100644 --- a/src/Integrations/Divi/Design/PresetArea.php +++ b/src/Integrations/Divi/Design/PresetArea.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Design; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; + defined('ABSPATH') || exit; /** @@ -27,6 +29,11 @@ final class PresetArea implements DesignArea return 'Presets'; } + public function feature(): DiviFeature + { + return DiviFeature::PresetWrite; + } + public function raw(): array { return PresetBook::fromDivi($this->writer->presets())->data(); diff --git a/src/Integrations/Divi/Library/DiviLibraryStore.php b/src/Integrations/Divi/Library/DiviLibraryStore.php index 18feaa6..714864a 100644 --- a/src/Integrations/Divi/Library/DiviLibraryStore.php +++ b/src/Integrations/Divi/Library/DiviLibraryStore.php @@ -222,11 +222,11 @@ class DiviLibraryStore implements DiviAdapter * when that delete fails; a throwing insert hook is a partial write without ID. * * @throws WriteFailed - * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + * @throws DiviLocked When library-write is not released (spec §5.5) – before anything is written. */ public function create(LibraryItemSpec $spec, NewDraft $draft): int { - ReleaseGate::assertWritable(); + ReleaseGate::assertFeature(DiviFeature::LibraryWrite); $missing = $this->missingTaxonomies(); if ($missing !== []) { throw WriteFailed::rolledBack( diff --git a/src/Integrations/Divi/Pages/DiviPages.php b/src/Integrations/Divi/Pages/DiviPages.php index 6e997f9..c6f69d8 100644 --- a/src/Integrations/Divi/Pages/DiviPages.php +++ b/src/Integrations/Divi/Pages/DiviPages.php @@ -61,11 +61,11 @@ final class DiviPages implements DiviAdapter /** * @throws RuntimeException When a flag does not read back as set. - * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + * @throws DiviLocked When page-write is not released (spec §5.5) – before anything is written. */ public static function setFlags(int $postId): void { - ReleaseGate::assertWritable($postId); + ReleaseGate::assertFeature(DiviFeature::PageWrite, $postId); foreach (self::FLAGS as $key => $value) { update_post_meta($postId, $key, $value); } diff --git a/src/Integrations/Divi/Pages/DiviSaver.php b/src/Integrations/Divi/Pages/DiviSaver.php index 79b5600..ea7a276 100644 --- a/src/Integrations/Divi/Pages/DiviSaver.php +++ b/src/Integrations/Divi/Pages/DiviSaver.php @@ -71,12 +71,12 @@ class DiviSaver /** * @return array{revision_id: ?int, warnings: list} * @throws WriteFailed - * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + * @throws DiviLocked When a feature of $scope is not released (spec §5.5, D5) – before anything is written. */ public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array { $postId = (int) $post->ID; - ReleaseGate::assertWritable($postId); + ReleaseGate::assertFeatures($scope->features(), $postId); $revisionId = null; try { @@ -142,11 +142,11 @@ class DiviSaver * * @return list Warnings (CHANGED_ON_SAVE). * @throws RuntimeException - * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + * @throws DiviLocked When a feature of $scope is not released (spec §5.5, D5) – before anything is written. */ public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array { - ReleaseGate::assertWritable($postId); + ReleaseGate::assertFeatures($scope->features(), $postId); $refusal = self::refusal($this->writeContent($postId, $content)); if ($refusal !== null) { throw new RuntimeException(sprintf('wp_update_post(%d) refused the Divi layout: %s', $postId, $refusal)); diff --git a/src/Integrations/Divi/Pages/SaveScope.php b/src/Integrations/Divi/Pages/SaveScope.php index b20eb9e..b1b3dd5 100644 --- a/src/Integrations/Divi/Pages/SaveScope.php +++ b/src/Integrations/Divi/Pages/SaveScope.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Pages; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; + defined('ABSPATH') || exit; // The installed PHPCompatibility sniffs predate enums (PHP 8.1) and read their methods as plain functions. @@ -33,4 +35,14 @@ enum SaveScope { return $this === self::GlobalItem || $this === self::ThemeBuilderLayout; } + + /** @return list What a write in this scope needs (D5): page-write and the feature of its area. */ + public function features(): array + { + return match ($this) { + self::Page => [DiviFeature::PageWrite], + self::LibraryItem, self::GlobalItem => [DiviFeature::PageWrite, DiviFeature::LibraryWrite], + self::ThemeBuilderLayout => [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite], + }; + } } diff --git a/src/Integrations/Divi/Release/DiviLocked.php b/src/Integrations/Divi/Release/DiviLocked.php index 090a662..08540d5 100644 --- a/src/Integrations/Divi/Release/DiviLocked.php +++ b/src/Integrations/Divi/Release/DiviLocked.php @@ -11,21 +11,28 @@ use WP_Error; defined('ABSPATH') || exit; -/** A write refused because the installed Divi is not released (spec §5.5); AbilityRegistrar returns toError(). */ +/** + * A Divi call refused because a feature it needs is not released (spec §5.5, D6); AbilityRegistrar returns + * toError(). Without features it is the general write lock of the overall status (texts of spec §5.6). + */ final class DiviLocked extends RuntimeException implements ToolRefusal { - public function __construct(public readonly ReleaseStatus $status) + /** @param list $features The locked features the refused call needs; [] = writing in general. */ + public function __construct(public readonly ReleaseStatus $status, public readonly array $features = []) { parent::__construct(sprintf( - '%s is %s for LIT MCP – write refused', + '%s is %s for LIT MCP – %s refused', $status->versionLabel(), - $status->state->value + $status->state->value, + $features === [] + ? 'write' + : implode(', ', array_map(static fn (DiviFeature $feature): string => $feature->value, $features)) )); } public function toError(): WP_Error { - return $this->status->refusal(); + return $this->features === [] ? $this->status->refusal() : $this->status->refusalFor($this->features); } /** The lock anywhere in $e's chain of previous exceptions (callers wrap failures in WriteFailed). */ diff --git a/src/Integrations/Divi/Release/ReleaseGate.php b/src/Integrations/Divi/Release/ReleaseGate.php index 102a3c4..3fe16fa 100644 --- a/src/Integrations/Divi/Release/ReleaseGate.php +++ b/src/Integrations/Divi/Release/ReleaseGate.php @@ -11,9 +11,9 @@ use Throwable; defined('ABSPATH') || exit; /** - * The release status of this request and the lock every writing Divi path asks (spec §5.5). whileProbing() lets the - * admin check write its throwaway page whatever the status – only that post, only during the call, and only from - * code (there is no parameter or option for it). + * The release status of this request and the lock every writing Divi path asks for the feature it needs (spec §5.5, + * E1). whileProbing() lets the admin check write its throwaway page whatever the status – only that post, only + * during the call, and only from code (there is no parameter or option for it). */ final class ReleaseGate { @@ -57,17 +57,49 @@ final class ReleaseGate self::$status = null; } - /** @throws DiviLocked */ - public static function assertWritable(?int $postId = null): void + public static function feature(DiviFeature $feature): FeatureStatus + { + return self::status()->matrix->of($feature); + } + + /** @throws DiviLocked When $feature is not usable – before anything is written. */ + public static function assertFeature(DiviFeature $feature, ?int $postId = null): void + { + self::assertFeatures([$feature], $postId); + } + + /** + * Every one of $features usable, else DiviLocked with the locked ones. The probe post passes whatever the status. + * + * @param list $features + * @throws DiviLocked + */ + public static function assertFeatures(array $features, ?int $postId = null): void { if (self::$probe !== null && $postId === self::$probe) { return; } $status = self::status(); - if (! $status->writable()) { - throw new DiviLocked($status); + $locked = []; + foreach ($features as $feature) { + if (! $status->matrix->of($feature)->usable()) { + $locked[$feature->value] = $feature; + } } + if ($locked !== []) { + throw new DiviLocked($status, array_values($locked)); + } + } + + /** + * Every writing feature (CompatibilityMatrix::writeFeatures()) – for callers that know no feature. + * + * @throws DiviLocked + */ + public static function assertWritable(?int $postId = null): void + { + self::assertFeatures(CompatibilityMatrix::writeFeatures(), $postId); } public static function whileProbing(int $postId, callable $write): mixed diff --git a/src/Integrations/Divi/Release/ReleaseStatus.php b/src/Integrations/Divi/Release/ReleaseStatus.php index 54d7780..67dc625 100644 --- a/src/Integrations/Divi/Release/ReleaseStatus.php +++ b/src/Integrations/Divi/Release/ReleaseStatus.php @@ -30,6 +30,15 @@ final class ReleaseStatus private const INCOMPATIBLE_HINT = 'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss; lesende ' . 'Divi-Werkzeuge funktionieren weiter.'; private const NOTICE = 'Achtung: %s ist für LIT MCP %s; Lesen funktioniert, Schreiben ist gesperrt.'; + private const FEATURE_UNTESTED = 'Achtung: %1$s ist auf dieser Website für %2$s noch nicht für LIT MCP ' + . 'freigegeben. Das ist gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung ' + . 'ausführt und die Version freigibt. Es wurde nichts geändert.'; + private const FEATURE_UNTESTED_HINT = 'Gesperrt: %s. Andere Divi-Werkzeuge funktionieren weiter, sofern ihre ' + . 'Fähigkeiten freigegeben sind. Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben.'; + private const FEATURE_INCOMPATIBLE = '%1$s hat Stellen geändert, die LIT MCP für %2$s nutzt. Das ist gesperrt, ' + . 'bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.'; + private const FEATURE_INCOMPATIBLE_HINT = 'Gesperrt: %s. Andere Divi-Werkzeuge funktionieren weiter, sofern ihre ' + . 'Fähigkeiten freigegeben sind. Den Nutzer informieren, dass LIT MCP aktualisiert werden muss.'; public readonly CompatibilityMatrix $matrix; @@ -99,6 +108,56 @@ final class ReleaseStatus }; } + /** + * The error of a call refused because some of $features are locked (D6): divi_incompatible when one of them is + * incompatible, else divi_untested; HTTP 423. The message names the features in that state, the hint every + * locked one; details.features lists them (id, label, state, problems), details.problems the problems of an + * incompatible refusal as before. + * + * @param list $features What the refused call needs. + * @throws LogicException When all of them are usable. + */ + public function refusalFor(array $features): WP_Error + { + $locked = []; + foreach ($features as $feature) { + $status = $this->matrix->of($feature); + if (! $status->usable()) { + $locked[$feature->value] = $status; + } + } + if ($locked === []) { + throw new LogicException('usable features refuse nothing'); + } + + $locked = array_values($locked); + $incompatible = array_values(array_filter( + $locked, + static fn (FeatureStatus $status): bool => $status->state === ReleaseState::Incompatible + )); + $details = ['features' => array_map(static fn (FeatureStatus $status): array => $status->summary(), $locked)]; + if ($incompatible !== []) { + $details['problems'] = array_values(array_unique(array_merge( + ...array_map(static fn (FeatureStatus $status): array => $status->problems, $locked) + ))); + } + + return Errors::make( + $incompatible !== [] ? 'divi_incompatible' : 'divi_untested', + sprintf( + $incompatible !== [] ? self::FEATURE_INCOMPATIBLE : self::FEATURE_UNTESTED, + $this->versionLabel(), + self::named($incompatible !== [] ? $incompatible : $locked) + ), + sprintf( + $incompatible !== [] ? self::FEATURE_INCOMPATIBLE_HINT : self::FEATURE_UNTESTED_HINT, + self::named($locked) + ), + 423, + $details + ); + } + /** The warning a reading Divi tool carries while writes are locked. */ public function notice(): string { @@ -109,6 +168,22 @@ final class ReleaseStatus ); } + /** + * "„Seiten schreiben“", "„A“ und „B“", "„A“, „B“ und „C“". + * + * @param non-empty-list $statuses + */ + private static function named(array $statuses): string + { + $labels = array_map( + static fn (FeatureStatus $status): string => '„' . $status->feature->label() . '“', + $statuses + ); + $last = array_pop($labels); + + return $labels === [] ? $last : implode(', ', $labels) . ' und ' . $last; + } + /** * @return array{state: string, version: string, source: ?string, released_at: ?string, released_by: ?int, * problems: list} diff --git a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php index d573431..c7b45e6 100644 --- a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php +++ b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php @@ -149,12 +149,12 @@ class DiviThemeBuilder implements DiviAdapter /** * The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. * - * @throws DiviLocked When $create and Divi is not released (spec §5.5) – before anything is written. + * @throws DiviLocked When $create and tb-write is not released (spec §5.5) – before anything is written. */ public function liveId(bool $create = false): int { if ($create) { - ReleaseGate::assertWritable(); + ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite); } return function_exists('et_theme_builder_get_theme_builder_post_id') @@ -261,11 +261,11 @@ class DiviThemeBuilder implements DiviAdapter /** * @throws RuntimeException - * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + * @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written. */ public function insertLayout(string $area, string $title): int { - ReleaseGate::assertWritable(); + ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite); $type = self::LAYOUT_TYPES[$area] ?? throw new RuntimeException('unknown Theme Builder area ' . $area); $this->assertCanWrite('et_theme_builder_insert_layout'); $id = $this->guarded( @@ -287,11 +287,11 @@ class DiviThemeBuilder implements DiviAdapter * @param array{title: string, layouts: array, use_on: list, * exclude_from: list} $template * @throws RuntimeException - * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + * @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written. */ public function storeTemplate(int $themeBuilderId, array $template): int { - ReleaseGate::assertWritable(); + ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite); $this->assertCanWrite('et_theme_builder_store_template', 'et_'); $payload = [ 'title' => $template['title'], @@ -321,11 +321,11 @@ class DiviThemeBuilder implements DiviAdapter * template unused (stage3b README finding 10) – detachedIds() finds it, callers unmarkUnused() on reattach. * * @throws RuntimeException - * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + * @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written. */ public function attach(int $themeBuilderId, int $templateId): void { - ReleaseGate::assertWritable(); + ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite); $existing = get_post_meta($themeBuilderId, self::TEMPLATE_LIST, false); if (is_array($existing) && in_array((string) $templateId, array_map('strval', $existing), true)) { return; @@ -342,10 +342,10 @@ class DiviThemeBuilder implements DiviAdapter } } - /** @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. */ + /** @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written. */ public function detach(int $themeBuilderId, int $templateId): void { - ReleaseGate::assertWritable(); + ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite); delete_post_meta($themeBuilderId, self::TEMPLATE_LIST, (string) $templateId); } @@ -356,11 +356,11 @@ class DiviThemeBuilder implements DiviAdapter * @param list $useOn * @param list $excludeFrom * @throws RuntimeException - * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + * @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written. */ public function setConditions(int $templateId, array $useOn, array $excludeFrom): void { - ReleaseGate::assertWritable(); + ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite); foreach ([self::USE_ON => $useOn, self::EXCLUDE_FROM => $excludeFrom] as $key => $conditions) { delete_post_meta($templateId, $key); foreach (array_values(array_unique($conditions)) as $condition) { @@ -373,11 +373,11 @@ class DiviThemeBuilder implements DiviAdapter /** * @throws RuntimeException - * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + * @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written. */ public function setEnabled(int $templateId, bool $enabled): void { - ReleaseGate::assertWritable(); + ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite); $value = $enabled ? '1' : '0'; update_post_meta($templateId, self::ENABLED, $value); if ((string) get_post_meta($templateId, self::ENABLED, true) !== $value) { @@ -388,11 +388,11 @@ class DiviThemeBuilder implements DiviAdapter /** * Removes Divi's "unused" mark (F19), as Divi's own save does for every listed post. * - * @throws DiviLocked When Divi is not released (spec §5.5) – before anything is written. + * @throws DiviLocked When tb-write is not released (spec §5.5) – before anything is written. */ public function unmarkUnused(int $postId): void { - ReleaseGate::assertWritable(); + ReleaseGate::assertFeature(DiviFeature::ThemeBuilderWrite); delete_post_meta($postId, self::UNUSED); } diff --git a/tests/Unit/Integrations/Divi/Design/DesignChangeTest.php b/tests/Unit/Integrations/Divi/Design/DesignChangeTest.php index 69bdbd4..a19a40c 100644 --- a/tests/Unit/Integrations/Divi/Design/DesignChangeTest.php +++ b/tests/Unit/Integrations/Divi/Design/DesignChangeTest.php @@ -11,12 +11,14 @@ use LIT\MCP\Integrations\Divi\Design\DesignPlan; use LIT\MCP\Integrations\Divi\Design\SnapshotStore; use LIT\MCP\Integrations\Divi\Design\SnapshotTooLarge; use LIT\MCP\Integrations\Divi\Library\UsageReport; +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\ReleaseState; use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; use LIT\MCP\Abilities\WriteFailed; use LIT\MCP\Tests\Support\FakeChangeLog; +use LIT\MCP\Tests\Support\ReleaseMatrices; use LIT\MCP\Tests\Unit\TestCase; use Brain\Monkey\Functions; use Mockery; @@ -80,6 +82,11 @@ final class DesignChangeTest extends TestCase return 'Presets'; } + public function feature(): DiviFeature + { + return DiviFeature::PresetWrite; + } + public function raw(): array { return $this->stored; @@ -383,6 +390,41 @@ final class DesignChangeTest extends TestCase ); } + public function testTheLockFollowsTheFeatureOfTheArea(): void + { + $others = array_values(array_filter( + DiviFeature::cases(), + static fn (DiviFeature $feature): bool => $feature !== DiviFeature::PresetWrite + )); + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status($others)); + $stored = ['v' => 1]; + $area = self::area($stored); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->expects('save')->andReturn('s1'); + $snapshots->expects('attachLog'); + + $written = (new DesignChange($snapshots, new FakeChangeLog(9)))->run( + 'lit/divi-presets-set', + $area, + ['confirm' => true, 'design_rev' => $area->rev($stored)], + self::plan(['v' => 2]) + ); + self::assertTrue($written['ok'], 'every other feature locked: presets are still written'); + + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([DiviFeature::PresetWrite])); + try { + (new DesignChange(Mockery::mock(SnapshotStore::class), FakeChangeLog::forbidden()))->run( + 'lit/divi-presets-set', + $area, + ['confirm' => true, 'design_rev' => $area->rev($stored)], + self::plan(['v' => 3]) + ); + self::fail('preset-write locked must refuse'); + } catch (DiviLocked $locked) { + self::assertSame([DiviFeature::PresetWrite], $locked->features); + } + } + public function testTooLargeSnapshotRefusesTheChange(): void { $stored = ['v' => 1]; diff --git a/tests/Unit/Integrations/Divi/Design/PresetAreaTest.php b/tests/Unit/Integrations/Divi/Design/PresetAreaTest.php index c97b68d..dcbc88d 100644 --- a/tests/Unit/Integrations/Divi/Design/PresetAreaTest.php +++ b/tests/Unit/Integrations/Divi/Design/PresetAreaTest.php @@ -7,6 +7,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Design\PresetArea; use LIT\MCP\Integrations\Divi\Design\PresetBook; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -23,6 +24,7 @@ final class PresetAreaTest extends TestCase $area = new PresetArea($writer); self::assertSame(['presets', 'Presets'], [$area->name(), $area->label()]); + self::assertSame(DiviFeature::PresetWrite, $area->feature()); self::assertSame(PresetBook::fromDivi($stored)->data(), $area->raw()); self::assertSame(PresetBook::fromDivi($stored)->rev(), $area->rev($stored)); self::assertSame(PresetBook::fromDivi($stored)->data(), $area->write($stored, true)); diff --git a/tests/Unit/Integrations/Divi/Pages/SaveScopeTest.php b/tests/Unit/Integrations/Divi/Pages/SaveScopeTest.php index cb08d40..f5fbde3 100644 --- a/tests/Unit/Integrations/Divi/Pages/SaveScopeTest.php +++ b/tests/Unit/Integrations/Divi/Pages/SaveScopeTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use LIT\MCP\Integrations\Divi\Pages\SaveScope; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use PHPUnit\Framework\TestCase; final class SaveScopeTest extends TestCase @@ -18,4 +19,16 @@ final class SaveScopeTest extends TestCase self::assertFalse(SaveScope::Page->clearsSiteCss()); self::assertFalse(SaveScope::LibraryItem->setsFlags()); } + + /** D5: every save needs page-write, a library or Theme Builder save the feature of its area too. */ + public function testFeaturesOfEachScope(): void + { + self::assertSame([DiviFeature::PageWrite], SaveScope::Page->features()); + self::assertSame([DiviFeature::PageWrite, DiviFeature::LibraryWrite], SaveScope::LibraryItem->features()); + self::assertSame([DiviFeature::PageWrite, DiviFeature::LibraryWrite], SaveScope::GlobalItem->features()); + self::assertSame( + [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite], + SaveScope::ThemeBuilderLayout->features() + ); + } } diff --git a/tests/Unit/Integrations/Divi/Release/DiviLockedTest.php b/tests/Unit/Integrations/Divi/Release/DiviLockedTest.php new file mode 100644 index 0000000..34887c2 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/DiviLockedTest.php @@ -0,0 +1,37 @@ +getMessage()); + self::assertEquals($status->refusalFor([DiviFeature::PresetWrite]), $locked->toError()); + $payload = self::assertToolError($locked->toError(), 'divi_incompatible'); + self::assertSame(['preset-write'], array_column($payload['features'], 'id')); + } + + public function testWithoutFeaturesTheErrorIsTheGeneralWriteLock(): void + { + $status = new ReleaseStatus(ReleaseState::Untested, '5.3.0'); + $locked = new DiviLocked($status); + + self::assertSame('Divi 5.3.0 is untested for LIT MCP – write refused', $locked->getMessage()); + self::assertEquals($status->refusal(), $locked->toError()); + } +} diff --git a/tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php b/tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php index 8225b3d..51739b2 100644 --- a/tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php +++ b/tests/Unit/Integrations/Divi/Release/ReleaseGateTest.php @@ -5,11 +5,13 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; use LIT\MCP\Abilities\WriteFailed; +use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix; 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\ReleaseState; use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; +use LIT\MCP\Tests\Support\ReleaseMatrices; use LIT\MCP\Tests\Unit\TestCase; use RuntimeException; @@ -20,10 +22,78 @@ final class ReleaseGateTest extends TestCase ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0')); } - public function testTheTestCaseDefaultIsReleased(): void + public function testTheTestCaseDefaultReleasesEveryFeature(): void { self::assertTrue(ReleaseGate::status()->writable()); + self::assertSame([], ReleaseGate::status()->matrix->unusable()); ReleaseGate::assertWritable(7); + ReleaseGate::assertFeatures(DiviFeature::cases()); + } + + public function testFeatureReadsTheMatrixOfThisRequest(): void + { + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([DiviFeature::Catalog])); + + self::assertSame(ReleaseState::Untested, ReleaseGate::feature(DiviFeature::Catalog)->state); + self::assertTrue(ReleaseGate::feature(DiviFeature::PageRead)->usable()); + } + + public function testAssertFeatureRefusesOnlyALockedFeature(): void + { + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status( + [DiviFeature::PresetWrite], + ReleaseState::Incompatible + )); + ReleaseGate::assertFeature(DiviFeature::PageWrite); + + try { + ReleaseGate::assertFeature(DiviFeature::PresetWrite, 7); + self::fail('a locked feature must refuse'); + } catch (DiviLocked $locked) { + self::assertSame([DiviFeature::PresetWrite], $locked->features); + self::assertToolError($locked->toError(), 'divi_incompatible'); + } + } + + public function testAssertFeaturesNamesEveryLockedFeatureOnce(): void + { + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status( + [DiviFeature::LibraryWrite, DiviFeature::ThemeBuilderWrite] + )); + + try { + ReleaseGate::assertFeatures([ + DiviFeature::PageWrite, + DiviFeature::ThemeBuilderWrite, + DiviFeature::LibraryWrite, + DiviFeature::ThemeBuilderWrite, + ]); + self::fail('locked features must refuse'); + } catch (DiviLocked $locked) { + self::assertSame([DiviFeature::ThemeBuilderWrite, DiviFeature::LibraryWrite], $locked->features); + } + } + + public function testAssertWritableNeedsEveryWritingFeature(): void + { + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([DiviFeature::Catalog])); + ReleaseGate::assertWritable(); + + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([DiviFeature::LibraryWrite])); + try { + ReleaseGate::assertWritable(); + self::fail('a locked writing feature must refuse'); + } catch (DiviLocked $locked) { + self::assertSame([DiviFeature::LibraryWrite], $locked->features); + } + + ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0')); + try { + ReleaseGate::assertWritable(); + self::fail('an untested Divi must lock writes'); + } catch (DiviLocked $locked) { + self::assertSame(CompatibilityMatrix::writeFeatures(), $locked->features); + } } public function testAssertWritableThrowsTheLockWithTheStatus(): void @@ -45,6 +115,7 @@ final class ReleaseGateTest extends TestCase $result = ReleaseGate::whileProbing(41, static function (): string { ReleaseGate::assertWritable(41); + ReleaseGate::assertFeatures(DiviFeature::cases(), 41); return 'written'; }); @@ -52,6 +123,10 @@ final class ReleaseGateTest extends TestCase foreach ([static fn () => ReleaseGate::whileProbing(41, static fn () => ReleaseGate::assertWritable(42)), static fn () => ReleaseGate::whileProbing(41, static fn () => ReleaseGate::assertWritable()), + static fn () => ReleaseGate::whileProbing(41, static fn () => ReleaseGate::assertFeature( + DiviFeature::PageWrite, + 42 + )), static fn () => ReleaseGate::assertWritable(41)] as $attempt) { try { $attempt(); @@ -118,7 +193,7 @@ final class ReleaseGateTest extends TestCase self::assertStringContainsString('theme API gone', (string) file_get_contents($log)); unlink($log); $this->expectException(DiviLocked::class); - ReleaseGate::assertWritable(); + ReleaseGate::assertFeature(DiviFeature::PageRead); } public function testFindsTheLockInsideAWrappedFailure(): void diff --git a/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php b/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php index be2c82d..3840c7e 100644 --- a/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php +++ b/tests/Unit/Integrations/Divi/Release/ReleaseGuardsTest.php @@ -5,10 +5,13 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; use Brain\Monkey\Functions; +use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec; use LIT\MCP\Integrations\Divi\Pages\DiviPages; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; +use LIT\MCP\Integrations\Divi\Pages\SaveScope; +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\ReleaseState; @@ -16,19 +19,26 @@ use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; use LIT\MCP\Integrations\WordPress\Content\NewDraft; use LIT\MCP\Tests\Support\FakeChangeLog; +use LIT\MCP\Tests\Support\ReleaseMatrices; use LIT\MCP\Tests\Unit\TestCase; +use RuntimeException; +use Throwable; use WP_Post; -/** Spec §5.5: every writing adapter method refuses before its first write while Divi is not released. */ +/** + * Spec §5.5, D5: every writing adapter method refuses before its first write exactly when a feature it needs is + * locked – and passes the gate when only other features are locked. (DesignChange: DesignChangeTest.) + */ final class ReleaseGuardsTest extends TestCase { + private const WRITES = ['wp_update_post', 'wp_insert_post', 'update_post_meta', 'add_post_meta', 'delete_post_meta', + 'update_option', 'delete_option', 'wp_set_object_terms']; + protected function setUp(): void { parent::setUp(); - ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0')); - // Nothing may be written: every write function WordPress offers fails the test. - foreach (['wp_update_post', 'wp_insert_post', 'update_post_meta', 'add_post_meta', 'delete_post_meta', - 'update_option', 'delete_option', 'wp_set_object_terms'] as $write) { + // Nothing may be written while locked: every write function WordPress offers fails the test. + foreach (self::WRITES as $write) { Functions\when($write)->alias(static function () use ($write): never { self::fail($write . ' ran although Divi is locked'); }); @@ -36,41 +46,106 @@ final class ReleaseGuardsTest extends TestCase Functions\when('current_user_can')->justReturn(true); } - /** @return iterable */ + /** @return iterable}> */ public static function writes(): iterable { $post = new WP_Post(['ID' => 7, 'post_content' => '', 'post_type' => 'page']); $spec = new LibraryItemSpec('Hero', 'section', false, 'regular', null, 'page'); + $page = [DiviFeature::PageWrite]; + $tb = [DiviFeature::ThemeBuilderWrite]; - yield 'save' => [static fn () => (new DiviSaver())->save($post, '')]; - yield 'saveNew' => [static fn () => (new DiviSaver())->saveNew(7, '')]; + yield 'save' => [static fn () => (new DiviSaver())->save($post, ''), $page]; + yield 'save library item' => [ + static fn () => (new DiviSaver())->save($post, 'x', SaveScope::LibraryItem), + [DiviFeature::PageWrite, DiviFeature::LibraryWrite], + ]; + yield 'save global item' => [ + static fn () => (new DiviSaver())->save($post, 'x', SaveScope::GlobalItem), + [DiviFeature::PageWrite, DiviFeature::LibraryWrite], + ]; + yield 'save tb layout' => [ + static fn () => (new DiviSaver())->save($post, 'x', SaveScope::ThemeBuilderLayout), + [DiviFeature::PageWrite, DiviFeature::ThemeBuilderWrite], + ]; + yield 'saveNew' => [static fn () => (new DiviSaver())->saveNew(7, ''), $page]; + yield 'saveNew library item' => [ + static fn () => (new DiviSaver())->saveNew(7, 'x', SaveScope::LibraryItem), + [DiviFeature::PageWrite, DiviFeature::LibraryWrite], + ]; + yield 'pages setFlags' => [static fn () => DiviPages::setFlags(7), $page]; yield 'library create' => [static fn () => (new DiviLibraryStore())->create( $spec, new NewDraft(null, FakeChangeLog::forbidden(), 'lit/library-save', 'et_pb_layout', 'Bibliothekseintrag') - )]; - yield 'tb insertLayout' => [static fn () => (new DiviThemeBuilder())->insertLayout('header', 'Kopf')]; + ), [DiviFeature::LibraryWrite]]; + yield 'tb liveId create' => [static fn () => (new DiviThemeBuilder())->liveId(true), $tb]; + yield 'tb insertLayout' => [static fn () => (new DiviThemeBuilder())->insertLayout('header', 'Kopf'), $tb]; yield 'tb storeTemplate' => [static fn () => (new DiviThemeBuilder())->storeTemplate(3, [ 'title' => 'V', 'layouts' => [], 'use_on' => [], 'exclude_from' => [], - ])]; - yield 'tb attach' => [static fn () => (new DiviThemeBuilder())->attach(3, 9)]; - yield 'tb detach' => [static fn () => (new DiviThemeBuilder())->detach(3, 9)]; - yield 'tb setConditions' => [static fn () => (new DiviThemeBuilder())->setConditions(9, ['singular'], [])]; - yield 'tb setEnabled' => [static fn () => (new DiviThemeBuilder())->setEnabled(9, true)]; - yield 'tb unmarkUnused' => [static fn () => (new DiviThemeBuilder())->unmarkUnused(9)]; - yield 'pages setFlags' => [static fn () => DiviPages::setFlags(7)]; - yield 'tb liveId create' => [static fn () => (new DiviThemeBuilder())->liveId(true)]; + ]), $tb]; + yield 'tb attach' => [static fn () => (new DiviThemeBuilder())->attach(3, 9), $tb]; + yield 'tb detach' => [static fn () => (new DiviThemeBuilder())->detach(3, 9), $tb]; + yield 'tb setConditions' => [static fn () => (new DiviThemeBuilder())->setConditions(9, ['singular'], []), $tb]; + yield 'tb setEnabled' => [static fn () => (new DiviThemeBuilder())->setEnabled(9, true), $tb]; + yield 'tb unmarkUnused' => [static fn () => (new DiviThemeBuilder())->unmarkUnused(9), $tb]; + yield 'design savePresets' => [ + static fn () => (new DesignSystemWriter())->savePresets(['module' => [], 'group' => []], false), + [DiviFeature::PresetWrite], + ]; } - /** @dataProvider writes */ - public function testRefusesBeforeTheFirstWrite(callable $write): void + /** + * @dataProvider writes + * @param list $needs + */ + public function testRefusesBeforeTheFirstWriteWhenAFeatureItNeedsIsLocked(callable $write, array $needs): void { - $this->expectException(DiviLocked::class); + foreach ([ReleaseState::Untested, ReleaseState::Incompatible] as $state) { + foreach ($needs as $feature) { + ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([$feature], $state)); - $write(); + try { + $write(); + self::fail(sprintf('%s %s must refuse', $feature->value, $state->value)); + } catch (DiviLocked $locked) { + self::assertSame([$feature], $locked->features); + } + } + } + } + + /** + * @dataProvider writes + * @param list $needs + */ + public function testPassesTheGateWhenOnlyOtherFeaturesAreLocked(callable $write, array $needs): void + { + $others = array_values(array_filter( + DiviFeature::cases(), + static fn (DiviFeature $feature): bool => ! in_array($feature, $needs, true) + )); + ReleaseGate::useResolver( + static fn (): ReleaseStatus => ReleaseMatrices::status($others, ReleaseState::Incompatible) + ); + // Past the gate the method may write or fail on the missing Divi – anything but the lock. + foreach (self::WRITES as $name) { + Functions\when($name)->alias(static function () use ($name): never { + throw new RuntimeException($name . ' reached'); + }); + } + + try { + $write(); + } catch (DiviLocked $locked) { + self::fail('refused although only other features are locked: ' . $locked->getMessage()); + } catch (Throwable) { + // Reached the code behind the gate. + } + self::addToAssertionCount(1); } public function testTheCheckPageMayBeWrittenWhileProbing(): void { + ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0')); Functions\when('wp_update_post')->justReturn(41); Functions\when('update_post_meta')->justReturn(true); Functions\when('get_post_meta')->alias( diff --git a/tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php b/tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php index 6ac7efb..3314b0f 100644 --- a/tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php +++ b/tests/Unit/Integrations/Divi/Release/ReleaseStatusTest.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix; use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\FeatureStatus; use LIT\MCP\Integrations\Divi\Release\ReleaseState; use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; use LIT\MCP\Tests\Support\ReleaseMatrices; @@ -132,4 +133,78 @@ final class ReleaseStatusTest extends TestCase new ReleaseStatus(ReleaseState::Released, '5.2.1', 'code', [], null, CompatibilityMatrix::allIncompatible('x')); } + + public function testRefusalForAnUntestedFeatureNamesItAndKeepsTheOthersWorking(): void + { + $error = ReleaseMatrices::status([DiviFeature::PageWrite]) + ->refusalFor([DiviFeature::PageWrite, DiviFeature::PageRead]); + $payload = self::assertToolError($error, 'divi_untested'); + + self::assertSame(423, $error->get_error_data()['status']); + + self::assertSame( + 'Achtung: Divi 5.3.0 ist auf dieser Website für „Seiten schreiben“ noch nicht für LIT MCP freigegeben. ' + . 'Das ist gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die Prüfung ausführt ' + . 'und die Version freigibt. Es wurde nichts geändert.', + $payload['message'] + ); + self::assertSame( + 'Gesperrt: „Seiten schreiben“. Andere Divi-Werkzeuge funktionieren weiter, sofern ihre Fähigkeiten ' + . 'freigegeben sind. Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben.', + $payload['hint'] + ); + self::assertSame( + [['id' => 'page-write', 'label' => 'Seiten schreiben', 'state' => 'untested', 'problems' => []]], + $payload['features'] + ); + self::assertArrayNotHasKey('problems', $payload); + } + + public function testRefusalForTakesTheWorstStateAndListsEveryLockedFeature(): void + { + $matrix = ReleaseMatrices::matrix([DiviFeature::ThemeBuilderWrite], ReleaseState::Incompatible); + $features = []; + foreach (DiviFeature::cases() as $feature) { + $features[$feature->value] = $matrix->of($feature); + } + $features['library-write'] = new FeatureStatus(DiviFeature::LibraryWrite, ReleaseState::Untested); + $status = ReleaseStatus::of('5.99.0', new CompatibilityMatrix($features)); + + $payload = self::assertToolError( + $status->refusalFor([DiviFeature::PageWrite, DiviFeature::LibraryWrite, DiviFeature::ThemeBuilderWrite]), + 'divi_incompatible' + ); + + self::assertSame( + 'Divi 5.99.0 hat Stellen geändert, die LIT MCP für „Theme Builder schreiben“ nutzt. Das ist gesperrt, bis ' + . 'LIT MCP aktualisiert wurde. Es wurde nichts geändert.', + $payload['message'] + ); + self::assertStringStartsWith( + 'Gesperrt: „Bibliothek schreiben“ und „Theme Builder schreiben“. Andere Divi-Werkzeuge', + $payload['hint'] + ); + self::assertStringEndsWith('dass LIT MCP aktualisiert werden muss.', $payload['hint']); + self::assertSame(['library-write', 'tb-write'], array_column($payload['features'], 'id')); + self::assertSame(['untested', 'incompatible'], array_column($payload['features'], 'state')); + self::assertSame(['Seiten: et_x() – Signatur geändert'], $payload['problems']); + } + + public function testRefusalForNamesThreeFeatures(): void + { + $locked = [DiviFeature::PageWrite, DiviFeature::LibraryWrite, DiviFeature::PresetWrite]; + $payload = self::assertToolError(ReleaseMatrices::status($locked)->refusalFor($locked), 'divi_untested'); + + self::assertStringStartsWith( + 'Gesperrt: „Seiten schreiben“, „Bibliothek schreiben“ und „Presets schreiben“.', + $payload['hint'] + ); + } + + public function testUsableFeaturesHaveNoRefusal(): void + { + $this->expectException(LogicException::class); + + ReleaseMatrices::status([DiviFeature::Catalog])->refusalFor([DiviFeature::PageWrite, DiviFeature::PageRead]); + } } diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index c43241a..269a3bc 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Unit; use Brain\Monkey; use Brain\Monkey\Functions; +use LIT\MCP\Integrations\Divi\Release\CompatibilityMatrix; use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; use LIT\MCP\Tests\Support\AssertsToolErrors; @@ -35,8 +36,10 @@ abstract class TestCase extends BaseTestCase 'sanitize_text_field' => static fn ($value): string => trim(strip_tags((string) $value)), ]); - // Divi is released unless a test says otherwise (the release gate would ask WordPress and Divi). - ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseStatus::released('5.2.1')); + // Every Divi feature is released unless a test says otherwise (the gate would ask WordPress and Divi). + ReleaseGate::useResolver( + static fn (): ReleaseStatus => ReleaseStatus::of('5.2.1', CompatibilityMatrix::allReleased()) + ); } protected function tearDown(): void