diff --git a/src/Admin/DiviTab.php b/src/Admin/DiviTab.php index f016240..3392dee 100644 --- a/src/Admin/DiviTab.php +++ b/src/Admin/DiviTab.php @@ -10,6 +10,8 @@ use LIT\MCP\Integrations\Divi\Release\CheckReport; use LIT\MCP\Integrations\Divi\Release\CheckReportStore; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\DiviEnvironment; +use LIT\MCP\Integrations\Divi\Release\FeatureOverview; +use LIT\MCP\Integrations\Divi\Release\FeatureStatus; use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Integrations\Divi\Release\ReleaseState; @@ -21,10 +23,12 @@ use LIT\MCP\Plugin; defined('ABSPATH') || exit; /** - * Tab "Divi" (spec §6): release status of the installed Divi, the latest check report, "Prüfung starten" (REST, - * DiviCheckRoute), "Divi freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (only - * for a database release). Loading the tab removes check pages a dead check left behind – in the page's load hook - * (removeOrphans()), not while rendering. + * Tab "Divi" (spec §6): release status of the installed Divi, the table of its features (state, source, problems, + * affected tools – E3), the latest check report, "Prüfung starten" (REST, DiviCheckRoute), "Divi + * freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (whenever a database release applies, + * whatever the overall state). Loading the tab removes check pages a dead check left behind – in the page's load + * hook (removeOrphans()), not while rendering. renderNotice() is the admin notice on every admin page while a + * feature is unusable. */ final class DiviTab implements AdminTab { @@ -38,6 +42,7 @@ final class DiviTab implements AdminTab /** * @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment. * @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey(). + * @param FeatureOverview|null $overview null = a table without the affected tools. */ public function __construct( private ?CheckReportStore $reports = null, @@ -45,7 +50,8 @@ final class DiviTab implements AdminTab private ?ChangeLog $log = null, private ?AdminPostAction $action = null, private ?Closure $version = null, - private ?Closure $stateKey = null + private ?Closure $stateKey = null, + private ?FeatureOverview $overview = null ) { } @@ -68,6 +74,55 @@ final class DiviTab implements AdminTab add_action('admin_post_' . self::RELEASE_ACTION, [$this, 'release']); add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']); add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'removeOrphans']); + $this->registerNotice(); + } + + /** The admin notice for administrators while a Divi feature is unusable (registerActions() runs in admin only). */ + public function registerNotice(): void + { + add_action('admin_notices', [$this, 'renderNotice']); + } + + /** + * Only for administrators, only with Divi active, only while a feature is not usable – and not on the Divi tab, + * which shows the same table. Reads the cached matrix (D7), so no check runs on an ordinary admin page. + */ + public function renderNotice(): void + { + if (! current_user_can('manage_options') || $this->version() === '' || $this->onDiviTab()) { + return; + } + + $unusable = ReleaseGate::status()->matrix->unusable(); + if ($unusable === []) { + return; + } + + printf( + '

%s %s

', + esc_html(sprintf( + /* translators: 1: Divi version, 2: comma separated feature names */ + __( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + 'LIT MCP: Mit Divi %1$s sind diese Fähigkeiten nicht nutzbar: %2$s. Die betroffenen Werkzeuge lehnen ab, alle anderen laufen weiter.', + 'wp-lit-mcp' + ), + $this->version(), + implode(', ', array_map( + static fn (FeatureStatus $status): string => $status->feature->label(), + $unusable + )) + )), + esc_url(SettingsPage::url(self::SLUG)), + esc_html__('Details im Reiter „Divi“', 'wp-lit-mcp') + ); + } + + private function onDiviTab(): bool + { + $screen = function_exists('get_current_screen') ? get_current_screen() : null; + + return is_object($screen) && $screen->id === SettingsPage::SCREEN_HOOK && $this->requested(); } /** @@ -153,7 +208,7 @@ final class DiviTab implements AdminTab echo '

' . esc_html__('Divi-Freigabe', 'wp-lit-mcp') . '

'; echo '

' . esc_html__( // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. - 'LIT MCP schreibt nur mit Divi-Versionen, die geprüft sind. Nach einem Divi-Update hier die Prüfung starten und die Version freigeben; bis dahin lesen die Divi-Werkzeuge nur.', + 'LIT MCP prüft nach einem Divi-Update jede Fähigkeit einzeln (z. B. „Seiten schreiben“). Ist sie nicht nutzbar, lehnen nur die Werkzeuge ab, die sie brauchen; alle anderen laufen weiter.', 'wp-lit-mcp' ) . '

'; if ($this->removed !== []) { @@ -168,6 +223,7 @@ final class DiviTab implements AdminTab } $this->renderStatus($status); + $this->renderFeatures($status); $this->renderForms($status, $report); if ($report !== null) { $this->renderReport($report); @@ -184,7 +240,7 @@ final class DiviTab implements AdminTab ); printf( '%s%s – %s', - esc_html__('Zustand', 'wp-lit-mcp'), + esc_html__('Gesamtzustand', 'wp-lit-mcp'), esc_html($this->stateLabel($status->state)), esc_html($this->explanation($status)) ); @@ -198,6 +254,80 @@ final class DiviTab implements AdminTab echo ''; } + private function renderFeatures(ReleaseStatus $status): void + { + echo '

' . esc_html__('Fähigkeiten', 'wp-lit-mcp') . '

'; + echo ''; + $headings = [ + __('Fähigkeit', 'wp-lit-mcp'), + __('Zustand', 'wp-lit-mcp'), + __('Quelle', 'wp-lit-mcp'), + __('Probleme', 'wp-lit-mcp'), + __('Betroffene Werkzeuge', 'wp-lit-mcp'), + ]; + foreach ($headings as $heading) { + echo ''; + } + echo ''; + foreach (($this->overview ?? FeatureOverview::withoutTools())->rows($status->matrix) as $row) { + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
' . esc_html($heading) . '
' . esc_html($row['label']) . '' . esc_html($this->stateLabel(ReleaseState::from($row['state']))) . '' . esc_html($this->sourceLabel($row['source'])) . ''; + $this->renderList($row['problems'], false); + echo ''; + $this->renderList($row['tools'], true); + echo '
'; + $this->renderAutomationNote(); + } + + /** @param list $items */ + private function renderList(array $items, bool $code): void + { + if ($items === []) { + echo '–'; + + return; + } + + echo '
    '; + foreach ($items as $item) { + echo $code ? '
  • ' . esc_html($item) . '
  • ' : '
  • ' . esc_html($item) . '
  • '; + } + echo '
'; + } + + private function sourceLabel(?string $source): string + { + return match ($source) { + ReleaseStatus::SOURCE_CODE => __('Plugin (code)', 'wp-lit-mcp'), + ReleaseStatus::SOURCE_DATABASE => __('Freigabe auf dieser Website (database)', 'wp-lit-mcp'), + ReleaseStatus::SOURCE_AUTO => __('Automatik (auto)', 'wp-lit-mcp'), + default => '–', + }; + } + + /** E1: what the automatic release does and how to switch it off. */ + private function renderAutomationNote(): void + { + echo '

' . esc_html__( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + 'Automatik: Ist die installierte Divi-Version im Plugin nicht als getestet hinterlegt, gibt LIT MCP jede Fähigkeit einzeln frei, wenn alle benutzten Divi-Funktionen vorhanden sind, ihre Aufrufe (Signaturen) und die Fingerabdrücke nachgebauter Divi-Abläufe unverändert sind und alle Konstanten definiert sind. Das prüft Aufrufformen, nicht das Verhalten von Divi. Fehlt für eine Stelle die Referenz, ist die Fähigkeit ungetestet; weicht etwas ab, ist sie inkompatibel.', + 'wp-lit-mcp' + ) . '

'; + echo '

' . sprintf( + /* translators: %s: filter name in a code element */ + esc_html__( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + 'Die Automatik abschalten: Filter %s auf false setzen (z. B. in einem mu-Plugin). Dann gelten nicht hinterlegte Divi-Versionen bis zur Freigabe unten als ungetestet.', + 'wp-lit-mcp' + ), + 'lit_mcp_divi_auto_release' + ) . '

'; + } + private function stateLabel(ReleaseState $state): string { return match ($state) { @@ -212,13 +342,14 @@ final class DiviTab implements AdminTab if ($status->state === ReleaseState::Incompatible) { return __( // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. - 'Divi hat Stellen geändert, die LIT MCP nutzt. Schreiben bleibt gesperrt, bis ein Plugin-Update von LIT MCP eingespielt ist.', + 'Divi hat Stellen geändert, die LIT MCP nutzt. Die betroffenen Fähigkeiten (Tabelle unten) bleiben gesperrt, bis ein Plugin-Update von LIT MCP eingespielt ist; alle anderen laufen weiter.', 'wp-lit-mcp' ); } if ($status->state === ReleaseState::Untested) { return __( - 'Diese Version ist nicht geprüft. Die Divi-Werkzeuge lesen nur, bis sie freigegeben ist.', + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + 'Diese Version ist nicht geprüft. Die Werkzeuge der ungetesteten Fähigkeiten (Tabelle unten) lehnen ab, bis die Version freigegeben ist; alle anderen laufen weiter.', 'wp-lit-mcp' ); } @@ -283,7 +414,8 @@ final class DiviTab implements AdminTab } } - if ($status->state === ReleaseState::Released && $status->source === ReleaseStatus::SOURCE_DATABASE) { + // A database release applies whatever the overall state: one incompatible feature must not hide the way back. + if ($status->source === ReleaseStatus::SOURCE_DATABASE || $status->release !== null) { $this->actionForm(self::WITHDRAW_ACTION, __('Freigabe zurückziehen', 'wp-lit-mcp'), 'secondary'); } } diff --git a/src/Integrations/Core/SiteStatusAbility.php b/src/Integrations/Core/SiteStatusAbility.php index 29eadaa..03ce5f4 100644 --- a/src/Integrations/Core/SiteStatusAbility.php +++ b/src/Integrations/Core/SiteStatusAbility.php @@ -31,8 +31,10 @@ final class SiteStatusAbility implements Ability { return 'Überblick: Website, WordPress-Version, eigener Benutzer und Rollen, ' . 'erkannte Integrationen (Divi, Pods, The Events Calendar). ' - . 'Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, und ' - . 'release, ob LIT MCP mit dieser Divi-Version schreiben darf (released, untested, incompatible).'; + . 'Bei Divi nennt problems Divi-Stellen, die nach einem Update fehlen oder sich geändert haben, ' + . 'release den Gesamtzustand und features je Fähigkeit (z. B. Seiten schreiben) Zustand ' + . '(released, untested, incompatible), Quelle (code, database, auto), Probleme und betroffene Werkzeuge. ' + . 'Werkzeuge, deren Fähigkeit nicht released ist, lehnen ab; alle anderen Divi-Werkzeuge laufen weiter.'; } public function inputSchema(): array @@ -49,12 +51,49 @@ final class SiteStatusAbility implements Ability 'wordpress' => ['type' => 'string'], 'user' => ['type' => 'object'], 'allowed_roles' => ['type' => 'array', 'items' => ['type' => 'string']], - 'components' => ['type' => 'array'], + 'components' => ['type' => 'array', 'items' => self::componentSchema()], ], 'required' => ['site', 'wordpress', 'user', 'allowed_roles', 'components'], ]; } + /** @return array */ + private static function componentSchema(): array + { + $text = ['type' => 'string']; + + return [ + 'type' => 'object', + 'properties' => [ + 'id' => $text, + 'active' => ['type' => 'boolean'], + 'version' => ['type' => ['string', 'null']], + 'problems' => ['type' => 'array', 'items' => $text], + 'release' => ['type' => ['object', 'null']], + 'features' => [ + 'type' => 'array', + 'description' => 'Nur bei Divi: Zustand je Fähigkeit.', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => $text, + 'label' => $text, + 'state' => ['type' => 'string', 'enum' => ['released', 'untested', 'incompatible']], + 'source' => [ + 'type' => ['string', 'null'], + 'description' => 'code, database, auto oder null (nicht nutzbar).', + ], + 'problems' => ['type' => 'array', 'items' => $text], + 'tools' => ['type' => 'array', 'items' => $text], + ], + 'required' => ['id', 'label', 'state', 'source', 'problems', 'tools'], + ], + ], + ], + 'required' => ['id', 'active', 'version', 'problems', 'release'], + ]; + } + public function annotations(): array { return Annotations::read(); diff --git a/src/Integrations/Divi/DiviEnvironmentComponent.php b/src/Integrations/Divi/DiviEnvironmentComponent.php index e3b3f64..664bd54 100644 --- a/src/Integrations/Divi/DiviEnvironmentComponent.php +++ b/src/Integrations/Divi/DiviEnvironmentComponent.php @@ -6,31 +6,47 @@ namespace LIT\MCP\Integrations\Divi; use LIT\MCP\Integrations\Core\EnvironmentComponent; use LIT\MCP\Integrations\Divi\Release\DiviEnvironment; +use LIT\MCP\Integrations\Divi\Release\FeatureOverview; use LIT\MCP\Integrations\Divi\Release\ReleaseGate; defined('ABSPATH') || exit; /** - * Divi in lit-site-status: version, the internals that are missing (DiviCompatibility) and the release status - * (spec §5.5); inactive without problems and release when Divi is not the template. + * Divi in lit-site-status: version, missing internals (DiviCompatibility), the release status and – per feature – + * state, source, problems and the tools it affects (E3). The features come from the matrix the release gate + * already holds (cached, D7), so a site-status call never recomputes the mirror fingerprints. */ final class DiviEnvironmentComponent implements EnvironmentComponent { - public function __construct(private ?DiviCompatibility $compatibility = null) + private FeatureOverview $overview; + + public function __construct(private ?DiviCompatibility $compatibility = null, ?FeatureOverview $overview = null) { + $this->overview = $overview ?? FeatureOverview::withoutTools(); } + /** + * @return array{id: string, active: bool, version: ?string, problems: list, release: ?array, + * features: list, + * tools: list}>} + */ public function describe(): array { $version = DiviEnvironment::version(); - $active = $version !== null; + if ($version === null) { + return ['id' => 'divi', 'active' => false, 'version' => null, 'problems' => [], 'release' => null, + 'features' => []]; + } + + $status = ReleaseGate::status(); return [ 'id' => 'divi', - 'active' => $active, + 'active' => true, 'version' => $version, - 'problems' => $active ? ($this->compatibility ?? new DiviCompatibility())->problems() : [], - 'release' => $active ? ReleaseGate::status()->toArray() : null, + 'problems' => ($this->compatibility ?? new DiviCompatibility())->problems(), + 'release' => $status->toArray(), + 'features' => $this->overview->rows($status->matrix), ]; } } diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 943ac16..2e998f0 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi; use Closure; +use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\ToolAvailability; use LIT\MCP\Admin\DiviTab; use LIT\MCP\Admin\SettingsPage; @@ -55,6 +56,7 @@ use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Release\CompatibilityCheck; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; +use LIT\MCP\Integrations\Divi\Release\FeatureOverview; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder; @@ -95,11 +97,14 @@ final class DiviIntegration implements Integration, Bootable * "Divi Theme Builder" gate their entries in the Divi tools (ruling P9, E9), lit/divi-insert gates * lit/divi-node-copy, lit-library-insert and content-create's divi input (audit M2), lit/library-insert * gates lit/divi-node-copy from a library entry (audit L5). null = no Tools tab: everything allowed. + * @param (Closure(): list)|null $allAbilities The abilities of every integration (Plugin passes + * IntegrationRegistry::abilities()); the tools a Divi feature affects are found among them (E3). */ public function __construct( private ContentSchema $schema, private ChangeLog $log, - private ?ToolAvailability $tools = null + private ?ToolAvailability $tools = null, + private ?Closure $allAbilities = null ) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); @@ -177,10 +182,16 @@ final class DiviIntegration implements Integration, Bootable return [new DiviLogoUsage()]; } - /** Port for lit-site-status: Divi's version, missing internals and release status. */ + /** Which tools each Divi feature affects – for lit-site-status and the Divi tab (E3). */ + public function featureOverview(): FeatureOverview + { + return new FeatureOverview($this->allAbilities ?? static fn (): array => []); + } + + /** Port for lit-site-status: Divi's version, missing internals, release status and features. */ public function environmentComponent(): EnvironmentComponent { - return new DiviEnvironmentComponent(); + return new DiviEnvironmentComponent(overview: $this->featureOverview()); } /** @@ -210,7 +221,10 @@ final class DiviIntegration implements Integration, Bootable (new DiviCheckRoute($check))->register(); DiviCheckCommand::register($check); - add_filter(SettingsPage::TABS_FILTER, fn (array $tabs): array => [...$tabs, new DiviTab(log: $this->log)]); + add_filter( + SettingsPage::TABS_FILTER, + fn (array $tabs): array => [...$tabs, new DiviTab(log: $this->log, overview: $this->featureOverview())] + ); } public function abilities(): array diff --git a/src/Integrations/Divi/Release/FeatureOverview.php b/src/Integrations/Divi/Release/FeatureOverview.php new file mode 100644 index 0000000..385a1e2 --- /dev/null +++ b/src/Integrations/Divi/Release/FeatureOverview.php @@ -0,0 +1,67 @@ + $abilities All registered abilities (IntegrationRegistry::abilities()). */ + public function __construct(private Closure $abilities) + { + } + + /** An overview that knows no tools (tests, and a Divi tab built without a registry). */ + public static function withoutTools(): self + { + return new self(static fn (): array => []); + } + + /** + * Tools whose diviFeatures([]) contain the feature, named as the MCP client sees them: direct tools + * lit-, indirect ones lit/. A tool that needs a feature only for certain input (content-create + * with divi) is not listed – its refusal names the feature. + * + * @return array> Feature ID => tool names. + */ + public function toolsByFeature(): array + { + $map = []; + foreach (($this->abilities)() as $ability) { + if (! $ability instanceof RequiresDiviFeatures) { + continue; + } + foreach ($ability->diviFeatures([]) as $feature) { + $map[$feature->value][] = $ability->isDirect() + ? str_replace('/', '-', $ability->name()) + : $ability->name(); + } + } + + return $map; + } + + /** + * @return list, + * tools: list}> + */ + public function rows(CompatibilityMatrix $matrix): array + { + $tools = $this->toolsByFeature(); + + return array_map( + static fn (array $row): array => $row + ['tools' => $tools[$row['id']] ?? []], + $matrix->toArray() + ); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 90538b6..06cf2bd 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -131,9 +131,15 @@ final class Plugin Limits $limits, ToolAvailability $tools ): IntegrationRegistry { - $divi = new DiviIntegration($schema, $changeLog, $tools); + // E3: lit-site-status and the Divi tab name the tools each feature affects; the registry exists only after + // the Divi integration, so it is asked at call time. + $registry = null; + $allAbilities = static function () use (&$registry): array { + return $registry instanceof IntegrationRegistry ? $registry->abilities() : []; + }; + $divi = new DiviIntegration($schema, $changeLog, $tools, $allAbilities); - return new IntegrationRegistry( + $registry = new IntegrationRegistry( new CoreIntegration(self::pluginDir() . '/docs/guides', [$divi->environmentComponent()]), new WordPressIntegration( $schema, @@ -147,6 +153,8 @@ final class Plugin $divi->library(), $divi->themeBuilder() ); + + return $registry; } public static function settingsPage(): ?SettingsPage diff --git a/tests/Support/FeatureFakeTool.php b/tests/Support/FeatureFakeTool.php new file mode 100644 index 0000000..26161a5 --- /dev/null +++ b/tests/Support/FeatureFakeTool.php @@ -0,0 +1,71 @@ + $features */ + public function __construct( + private string $name, + private bool $direct, + private array $features + ) { + } + + public function name(): string + { + return $this->name; + } + + public function label(): string + { + return $this->name; + } + + public function description(): string + { + return ''; + } + + public function inputSchema(): array + { + return ['type' => 'object']; + } + + public function outputSchema(): ?array + { + return null; + } + + public function annotations(): array + { + return []; + } + + public function isDirect(): bool + { + return $this->direct; + } + + public function permission(array $input): bool|WP_Error + { + return true; + } + + public function execute(array $input): mixed + { + return []; + } + + public function diviFeatures(array $input): array + { + return $this->features; + } +} diff --git a/tests/Support/MatrixFixtures.php b/tests/Support/MatrixFixtures.php new file mode 100644 index 0000000..e46d524 --- /dev/null +++ b/tests/Support/MatrixFixtures.php @@ -0,0 +1,40 @@ + $replace Statuses that replace the released one of their feature. + */ + public static function released(string $source = 'code', array $replace = []): CompatibilityMatrix + { + $features = []; + foreach (DiviFeature::cases() as $feature) { + $features[$feature->value] = new FeatureStatus($feature, ReleaseState::Released, [], $source); + } + foreach ($replace as $status) { + $features[$status->feature->value] = $status; + } + + return new CompatibilityMatrix($features); + } + + /** @param string ...$problems */ + public static function with( + DiviFeature $feature, + ReleaseState $state, + string $source = 'code', + string ...$problems + ): CompatibilityMatrix { + return self::released($source, [new FeatureStatus($feature, $state, $problems, null)]); + } +} diff --git a/tests/Unit/Admin/DiviTabTest.php b/tests/Unit/Admin/DiviTabTest.php index 22210fc..2f8ff94 100644 --- a/tests/Unit/Admin/DiviTabTest.php +++ b/tests/Unit/Admin/DiviTabTest.php @@ -11,6 +11,9 @@ use LIT\MCP\Admin\DiviTab; use LIT\MCP\Integrations\Divi\Release\CheckReport; use LIT\MCP\Integrations\Divi\Release\CheckReportStore; use LIT\MCP\Integrations\Divi\Release\CheckStep; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\FeatureOverview; +use LIT\MCP\Integrations\Divi\Release\FeatureStatus; use LIT\MCP\Integrations\Divi\Release\ReleaseGate; use LIT\MCP\Integrations\Divi\Release\ReleaseState; use LIT\MCP\Integrations\Divi\Release\ReleaseStatus; @@ -18,6 +21,8 @@ use LIT\MCP\Integrations\Divi\Release\ReleaseStore; use LIT\MCP\Integrations\Divi\Release\WriteProbe; use LIT\MCP\Plugin; use LIT\MCP\Tests\Support\FakeChangeLog; +use LIT\MCP\Tests\Support\FeatureFakeTool; +use LIT\MCP\Tests\Support\MatrixFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; use WP_Post; @@ -63,8 +68,12 @@ final class DiviTabTest extends TestCase return new CheckReport('r1', '2026-10-01T08:00:00+00:00', 1, $version, Plugin::VERSION, $key, $steps); } - private function tab(?CheckReport $report, ReleaseStore $releases, ?FakeChangeLog $log = null): DiviTab - { + private function tab( + ?CheckReport $report, + ReleaseStore $releases, + ?FakeChangeLog $log = null, + ?FeatureOverview $overview = null + ): DiviTab { $reports = Mockery::mock(CheckReportStore::class); $reports->allows('latest')->andReturn($report); @@ -75,7 +84,8 @@ final class DiviTabTest extends TestCase new AdminPostAction(static function (): void { }), static fn (): string => '5.3.0', - static fn (string $version): string => 'key' + static fn (string $version): string => 'key', + $overview ); } @@ -154,6 +164,19 @@ final class DiviTabTest extends TestCase /** @return string The rendered tab. */ private function rendered(ReleaseStatus $status, ?CheckReport $report, ?DiviTab $tab = null): string + { + return $this->renderedTab($status, $tab ?? $this->tab($report, Mockery::mock(ReleaseStore::class))); + } + + private function renderedWith(ReleaseStatus $status, ?CheckReport $report, FeatureOverview $overview): string + { + return $this->renderedTab( + $status, + $this->tab($report, Mockery::mock(ReleaseStore::class), null, $overview) + ); + } + + private function renderedTab(ReleaseStatus $status, DiviTab $tab): string { ReleaseGate::useResolver(static fn (): ReleaseStatus => $status); Functions\when('rest_url')->justReturn('https://k.de/wp-json/lit-mcp/v1/divi-check'); @@ -167,7 +190,7 @@ final class DiviTabTest extends TestCase Functions\when('get_option')->justReturn('d.m.Y'); ob_start(); - ($tab ?? $this->tab($report, Mockery::mock(ReleaseStore::class)))->render(); + $tab->render(); return (string) ob_get_clean(); } @@ -331,4 +354,191 @@ final class DiviTabTest extends TestCase static fn (): string => '' ))->removeOrphans(); } + + private static function mixedStatus(): ReleaseStatus + { + $matrix = MatrixFixtures::released('auto', [ + new FeatureStatus( + DiviFeature::PageWrite, + ReleaseState::Incompatible, + ['Seiten: Signatur von et_pb_x() '], + null + ), + new FeatureStatus(DiviFeature::Catalog, ReleaseState::Untested, [], null), + ]); + + return new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, [], null, matrix: $matrix); + } + + public function testTheFeatureTableShowsStateSourceProblemsAndTools(): void + { + $overview = new FeatureOverview(static fn (): array => [ + new FeatureFakeTool('lit/divi-insert', true, [DiviFeature::PageRead, DiviFeature::PageWrite]), + new FeatureFakeTool('lit/divi-presets-assign', false, [DiviFeature::PresetWrite]), + ]); + + $html = $this->renderedWith(self::mixedStatus(), null, $overview); + + self::assertStringContainsString('Betroffene Werkzeuge', $html); + self::assertStringContainsString( + 'Seiten schreibeninkompatibel–', + $html + ); + self::assertStringContainsString('Modulkatalogungetestet', $html); + self::assertStringContainsString( + 'Seiten lesenfreigegebenAutomatik (auto)', + $html + ); + self::assertStringContainsString('
  • lit-divi-insert
  • ', $html); + self::assertStringContainsString('
  • lit/divi-presets-assign
  • ', $html); + } + + public function testFeatureProblemsAreEscaped(): void + { + $html = $this->rendered(self::mixedStatus(), null); + + self::assertStringContainsString('Seiten: Signatur von et_pb_x() <geändert>', $html); + self::assertStringNotContainsString('', $html); + } + + public function testTheTableNamesEverySourceInWords(): void + { + $code = $this->rendered(ReleaseStatus::released('5.3.0'), null); + $database = $this->rendered( + new ReleaseStatus(ReleaseState::Released, '5.3.0', ReleaseStatus::SOURCE_DATABASE, [], [ + 'released_at' => '2026-10-01T08:00:00+00:00', 'user_id' => 1, + ], matrix: MatrixFixtures::released('database')), + self::report() + ); + + self::assertStringContainsString('Plugin (code)', $code); + self::assertStringContainsString('Freigabe auf dieser Website (database)', $database); + } + + public function testTheTabExplainsTheAutomationAndTheFilter(): void + { + $html = $this->rendered(ReleaseStatus::released('5.3.0'), null); + + self::assertStringContainsString('gibt LIT MCP jede Fähigkeit einzeln frei', $html); + self::assertStringContainsString('lit_mcp_divi_auto_release', $html); + self::assertStringContainsString('als ungetestet', $html); + self::assertStringContainsString('Aufrufe', $html); + self::assertStringNotContainsString('Verhalten geprüft', $html); + } + + public function testTheTabNoLongerSaysWritingIsLockedAsAWhole(): void + { + $untested = $this->rendered( + new ReleaseStatus( + ReleaseState::Untested, + '5.3.0', + matrix: MatrixFixtures::with(DiviFeature::Catalog, ReleaseState::Untested) + ), + null + ); + $incompatible = $this->rendered(self::mixedStatus(), null); + + foreach ([$untested, $incompatible] as $html) { + self::assertStringNotContainsString('Schreiben bleibt gesperrt', $html); + self::assertStringNotContainsString('lesen nur', $html); + self::assertStringContainsString('alle anderen laufen weiter', $html); + } + } + + public function testADatabaseReleaseCanBeWithdrawnWhileOneFeatureIsIncompatible(): void + { + $matrix = MatrixFixtures::released('database', [ + new FeatureStatus(DiviFeature::PageWrite, ReleaseState::Incompatible, ['Seiten: x() fehlt'], null), + ]); + $status = new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', ReleaseStatus::SOURCE_DATABASE, [], [ + 'released_at' => '2026-10-01T08:00:00+00:00', 'user_id' => 1, + ], matrix: $matrix); + + self::assertStringContainsString('Freigabe zurückziehen', $this->rendered($status, self::report())); + } + + public function testWithoutADatabaseReleaseNothingCanBeWithdrawnWhateverTheState(): void + { + self::assertStringNotContainsString('Freigabe zurückziehen', $this->rendered(self::mixedStatus(), null)); + } + + /** @return string The admin notice of this request. */ + private function notice(ReleaseStatus $status, string $version = '5.3.0'): string + { + ReleaseGate::useResolver(static fn (): ReleaseStatus => $status); + $tab = new DiviTab( + Mockery::mock(CheckReportStore::class), + Mockery::mock(ReleaseStore::class), + FakeChangeLog::forbidden(), + new AdminPostAction(static function (): void { + }), + static fn () => $version, + static fn (string $v): string => 'key' + ); + + ob_start(); + $tab->renderNotice(); + + return (string) ob_get_clean(); + } + + public function testTheNoticeNamesTheUnusableFeaturesAndLinksToTheTab(): void + { + $html = $this->notice(self::mixedStatus()); + + self::assertStringContainsString('class="notice notice-warning"', $html); + self::assertStringContainsString('Mit Divi 5.3.0 sind diese Fähigkeiten nicht nutzbar', $html); + self::assertStringContainsString('Seiten schreiben, Modulkatalog', $html); + self::assertStringContainsString('alle anderen laufen weiter', $html); + self::assertStringContainsString('page=lit-mcp&tab=divi', $html); + } + + public function testNoNoticeWhileEveryFeatureIsUsable(): void + { + self::assertSame('', $this->notice(ReleaseStatus::released('5.3.0'))); + } + + public function testNoNoticeForUsersWithoutManageOptions(): void + { + Functions\when('current_user_can')->justReturn(false); + + self::assertSame('', $this->notice(self::mixedStatus())); + } + + public function testNoNoticeWithoutDivi(): void + { + self::assertSame('', $this->notice(self::mixedStatus(), '')); + } + + public function testNoNoticeOnTheDiviTabItself(): void + { + Functions\when('get_current_screen')->justReturn((object) ['id' => 'settings_page_lit-mcp']); + $_GET['tab'] = 'divi'; + + self::assertSame('', $this->notice(self::mixedStatus())); + } + + public function testTheNoticeStillShowsOnOtherTabsOfThePluginPage(): void + { + Functions\when('get_current_screen')->justReturn((object) ['id' => 'settings_page_lit-mcp']); + $_GET['tab'] = 'tools'; + + self::assertStringContainsString('nicht nutzbar', $this->notice(self::mixedStatus())); + } + + public function testTheNoticeIsRegisteredOnAdminNotices(): void + { + $tab = $this->tab(null, Mockery::mock(ReleaseStore::class)); + Actions\expectAdded('admin_notices')->once()->with([$tab, 'renderNotice']); + + $tab->registerNotice(); + } + + public function testRegisteringTheTabsActionsAlsoRegistersTheNotice(): void + { + $tab = $this->tab(null, Mockery::mock(ReleaseStore::class)); + Actions\expectAdded('admin_notices')->once()->with([$tab, 'renderNotice']); + + $tab->registerActions(); + } } diff --git a/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php b/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php index 4382bc8..505fe42 100644 --- a/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php +++ b/tests/Unit/Integrations/Core/SiteStatusAbilityTest.php @@ -33,4 +33,28 @@ final class SiteStatusAbilityTest extends TestCase self::assertSame(['administrator', 'editor'], $result['allowed_roles']); self::assertSame('divi', $result['components'][0]['id']); } + + public function testTheOutputSchemaDescribesTheDiviFeatures(): void + { + $schema = (new SiteStatusAbility(Mockery::mock(EnvironmentProbe::class), new Access()))->outputSchema(); + $component = $schema['properties']['components']['items']; + $feature = $component['properties']['features']['items']; + + self::assertSame(['id', 'active', 'version', 'problems', 'release'], $component['required']); + self::assertSame( + ['id', 'label', 'state', 'source', 'problems', 'tools'], + $feature['required'] + ); + self::assertSame(['released', 'untested', 'incompatible'], $feature['properties']['state']['enum']); + self::assertSame(['string', 'null'], $feature['properties']['source']['type']); + } + + public function testTheDescriptionNamesTheFeaturesAndSaysOtherToolsKeepWorking(): void + { + $description = (new SiteStatusAbility(Mockery::mock(EnvironmentProbe::class), new Access()))->description(); + + self::assertStringContainsString('features je Fähigkeit', $description); + self::assertStringContainsString('(code, database, auto)', $description); + self::assertStringContainsString('alle anderen Divi-Werkzeuge laufen weiter', $description); + } } diff --git a/tests/Unit/Integrations/Divi/DiviEnvironmentComponentTest.php b/tests/Unit/Integrations/Divi/DiviEnvironmentComponentTest.php index 3643d86..7be4965 100644 --- a/tests/Unit/Integrations/Divi/DiviEnvironmentComponentTest.php +++ b/tests/Unit/Integrations/Divi/DiviEnvironmentComponentTest.php @@ -8,6 +8,13 @@ use Brain\Monkey\Functions; use LIT\MCP\Integrations\Core\EnvironmentComponent; use LIT\MCP\Integrations\Divi\DiviCompatibility; use LIT\MCP\Integrations\Divi\DiviEnvironmentComponent; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; +use LIT\MCP\Integrations\Divi\Release\FeatureOverview; +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\FeatureFakeTool; +use LIT\MCP\Tests\Support\MatrixFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -61,8 +68,78 @@ final class DiviEnvironmentComponentTest extends TestCase ))->describe(); self::assertSame( - ['id' => 'divi', 'active' => false, 'version' => null, 'problems' => [], 'release' => null], + ['id' => 'divi', 'active' => false, 'version' => null, 'problems' => [], 'release' => null, + 'features' => []], $described ); } + + public function testListsEveryFeatureWithStateSourceProblemsAndTools(): void + { + $this->theme('Divi', '5.3.0'); + $matrix = MatrixFixtures::with( + DiviFeature::PageWrite, + ReleaseState::Incompatible, + 'auto', + 'Seiten: Signatur von et_pb_x() geändert' + ); + ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus( + ReleaseState::Incompatible, + '5.3.0', + null, + [], + null, + matrix: $matrix + )); + $overview = new FeatureOverview(static fn (): array => [ + new FeatureFakeTool('lit/divi-outline', true, [DiviFeature::PageRead]), + new FeatureFakeTool('lit/divi-insert', true, [DiviFeature::PageRead, DiviFeature::PageWrite]), + ]); + + $component = (new DiviEnvironmentComponent(new DiviCompatibility([]), $overview))->describe(); + $features = array_column($component['features'], null, 'id'); + + self::assertSame('divi', $component['id']); + self::assertCount(count(DiviFeature::cases()), $features); + self::assertSame('incompatible', $features['page-write']['state']); + self::assertNull($features['page-write']['source']); + self::assertSame(['Seiten: Signatur von et_pb_x() geändert'], $features['page-write']['problems']); + self::assertSame(['lit-divi-insert'], $features['page-write']['tools']); + self::assertSame('Seiten schreiben', $features['page-write']['label']); + self::assertSame('released', $features['page-read']['state']); + self::assertSame('auto', $features['page-read']['source']); + self::assertSame(['lit-divi-outline', 'lit-divi-insert'], $features['page-read']['tools']); + self::assertSame('incompatible', $component['release']['state']); + } + + public function testTheMatrixComesFromTheGateAndIsNotComputedAgain(): void + { + $this->theme('Divi', '5.3.0'); + $calls = 0; + ReleaseGate::useResolver(static function () use (&$calls): ReleaseStatus { + ++$calls; + + return ReleaseStatus::released('5.3.0'); + }); + $component = new DiviEnvironmentComponent(new DiviCompatibility([])); + + $component->describe(); + $component->describe(); + + self::assertSame(1, $calls); + } + + public function testWithoutDiviThereAreNoFeaturesAndNoGateCall(): void + { + $this->theme('twentytwentyfive', '1.0'); + ReleaseGate::useResolver(static function (): ReleaseStatus { + self::fail('the gate must not be asked without Divi'); + }); + + $component = (new DiviEnvironmentComponent(new DiviCompatibility([])))->describe(); + + self::assertFalse($component['active']); + self::assertSame([], $component['features']); + self::assertNull($component['release']); + } } diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index b4d1378..85d4f8d 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -20,6 +20,7 @@ use LIT\MCP\Integrations\Divi\DiviIntegration; use LIT\MCP\Integrations\Divi\DiviLogoUsage; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Integrations\Divi\Release\ReleaseResolver; use LIT\MCP\Integrations\Integration; use LIT\MCP\Integrations\IntegrationRegistry; @@ -27,6 +28,7 @@ use LIT\MCP\Integrations\Media\MediaUsageSource; use LIT\MCP\Integrations\WordPress\BuilderDetector; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Tests\Support\FakeChangeLog; +use LIT\MCP\Tests\Support\FeatureFakeTool; use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -327,4 +329,28 @@ final class DiviIntegrationTest extends TestCase self::assertSame($base, $all[0]); self::assertInstanceOf(DiviTab::class, $all[1]); } + + public function testTheFeatureOverviewFindsTheToolsOfAllIntegrations(): void + { + $integration = new DiviIntegration( + Mockery::mock(ContentSchema::class), + FakeChangeLog::forbidden(), + allAbilities: static fn (): array => [ + new FeatureFakeTool('lit/divi-insert', true, [DiviFeature::PageWrite]), + new FeatureFakeTool('lit/library-insert', true, [DiviFeature::PageWrite, DiviFeature::LibraryRead]), + ] + ); + + self::assertSame( + ['page-write' => ['lit-divi-insert', 'lit-library-insert'], 'library-read' => ['lit-library-insert']], + $integration->featureOverview()->toolsByFeature() + ); + } + + public function testWithoutAnAbilityListTheOverviewKnowsNoTools(): void + { + $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()); + + self::assertSame([], $integration->featureOverview()->toolsByFeature()); + } } diff --git a/tests/Unit/Integrations/Divi/Release/FeatureOverviewTest.php b/tests/Unit/Integrations/Divi/Release/FeatureOverviewTest.php new file mode 100644 index 0000000..33ae2b3 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Release/FeatureOverviewTest.php @@ -0,0 +1,76 @@ + [ + new FeatureFakeTool('lit/divi-outline', true, [DiviFeature::PageRead]), + new FeatureFakeTool('lit/divi-insert', true, [DiviFeature::PageRead, DiviFeature::PageWrite]), + new FeatureFakeTool('lit/divi-presets-assign', false, [DiviFeature::PresetWrite]), + new FeatureFakeTool('lit/content-create', true, []), + ]); + } + + public function testToolsAreNamedAsTheClientSeesThem(): void + { + $tools = self::overview()->toolsByFeature(); + + self::assertSame(['lit-divi-outline', 'lit-divi-insert'], $tools['page-read']); + self::assertSame(['lit-divi-insert'], $tools['page-write']); + self::assertSame(['lit/divi-presets-assign'], $tools['preset-write']); + } + + public function testAToolWithoutFeaturesIsLeftOut(): void + { + $overview = new FeatureOverview(static fn (): array => [new FeatureFakeTool('lit/content-create', true, [])]); + + self::assertSame([], $overview->toolsByFeature()); + } + + public function testRowsCarryTheMatrixAndTheToolsOfEachFeature(): void + { + $matrix = MatrixFixtures::with( + DiviFeature::PageWrite, + ReleaseState::Incompatible, + 'code', + 'Seiten: et_pb_x() fehlt' + ); + + $rows = array_column(self::overview()->rows($matrix), null, 'id'); + + self::assertCount(count(DiviFeature::cases()), $rows); + self::assertSame( + [ + 'id' => 'page-write', + 'label' => 'Seiten schreiben', + 'state' => 'incompatible', + 'problems' => ['Seiten: et_pb_x() fehlt'], + 'source' => null, + 'tools' => ['lit-divi-insert'], + ], + $rows['page-write'] + ); + self::assertSame('released', $rows['page-read']['state']); + self::assertSame('code', $rows['page-read']['source']); + self::assertSame([], $rows['catalog']['tools']); + } + + public function testWithoutToolsEveryRowHasAnEmptyToolList(): void + { + foreach (FeatureOverview::withoutTools()->rows(MatrixFixtures::released()) as $row) { + self::assertSame([], $row['tools']); + } + } +}