From e6e0f914ba74bf7c3034add6d3df142c10fef0cf Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 18 Oct 2026 21:56:36 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20ToolAvailability=20ersetzt=20die=20?= =?UTF-8?q?sechs=20Referenz-Closures=20in=20Plugin=20(Frage=20vor=20der=20?= =?UTF-8?q?Verdrahtung=20wirft=20statt=20still=20false);=20DiviIntegration?= =?UTF-8?q?=20bekommt=20den=20Tools-Tab-Zustand=20als=20Objekt;=20Plugin::?= =?UTF-8?q?integrations()=20mit=20Verdrahtungstest=20f=C3=BCr=20Reihenfolg?= =?UTF-8?q?e,=20einmaliges=20Booten=20und=20sp=C3=A4tes=20Binden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/Abilities/ToolAvailability.php | 63 +++++++++++ src/Integrations/Divi/DiviIntegration.php | 83 ++++++++------ src/Plugin.php | 93 ++++++---------- tests/Unit/Abilities/ToolAvailabilityTest.php | 104 ++++++++++++++++++ .../Integrations/Divi/DiviIntegrationTest.php | 71 +++++++++--- tests/Unit/PluginTest.php | 71 ++++++++++++ 6 files changed, 376 insertions(+), 109 deletions(-) create mode 100644 src/Abilities/ToolAvailability.php create mode 100644 tests/Unit/Abilities/ToolAvailabilityTest.php create mode 100644 tests/Unit/PluginTest.php diff --git a/src/Abilities/ToolAvailability.php b/src/Abilities/ToolAvailability.php new file mode 100644 index 0000000..13f28d6 --- /dev/null +++ b/src/Abilities/ToolAvailability.php @@ -0,0 +1,63 @@ +policy !== null) { + throw new LogicException('ToolAvailability is already bound.'); + } + + $this->registry = $registry; + $this->policy = $policy; + } + + /** Whether $group is switched on at all (ExposurePolicy::groupEnabled()). */ + public function groupEnabled(string $group): bool + { + return $this->policy()->groupEnabled($group); + } + + /** Whether abilities of $group may write (ExposurePolicy::groupWritable()). */ + public function groupWritable(string $group): bool + { + return $this->policy()->groupWritable($group); + } + + /** Whether the ability named $ability is registered and not disabled (ExposurePolicy::allows()). */ + public function allows(string $ability): bool + { + $policy = $this->policy(); + + return $policy->allows($ability, $this->registry()->abilities()); + } + + private function policy(): ExposurePolicy + { + return $this->policy ?? throw new LogicException('ToolAvailability asked before Plugin bound it.'); + } + + private function registry(): IntegrationRegistry + { + return $this->registry ?? throw new LogicException('ToolAvailability asked before Plugin bound it.'); + } +} diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 28efbf6..943ac16 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\ToolAvailability; use LIT\MCP\Admin\DiviTab; use LIT\MCP\Admin\SettingsPage; use LIT\MCP\ChangeLog\ChangeLog; @@ -83,40 +84,42 @@ final class DiviIntegration implements Integration, Bootable private ThemeBuilderAccess $themeBuilderAccess; private DesignSystemWriter $designWriter; + /** @var (Closure(): bool)|null Whether lit/divi-insert is enabled (ruling P9, E9, audit M2); null = always. */ + private ?Closure $diviWritable; + + /** @var (Closure(): bool)|null Whether lit/library-insert is enabled (audit L5); null = always. */ + private ?Closure $libraryInsertable; + /** - * @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries - * (group "Divi-Bibliothek" writable); null = always. - * @param (Closure(): bool)|null $libraryVisible Tools-tab gate for library entries at all (group - * "Divi-Bibliothek" not disabled, ruling P9); null = always. - * @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled – lit/divi-node-copy - * and lit-library-insert follow it (ruling P9, E9, audit M2); null = always. - * @param (Closure(): bool)|null $libraryInsertable Whether lit/library-insert is enabled – - * lit/divi-node-copy follows it for library entries as source (audit L5); null = always. - * @param (Closure(): bool)|null $themeBuilderWritable Tools-tab gate for writes to Theme Builder layouts in - * the Divi tools (group "Divi Theme Builder" writable, E9); null = always. - * @param (Closure(): bool)|null $themeBuilderVisible Tools-tab gate for Theme Builder layouts in the Divi - * tools at all (group "Divi Theme Builder" not disabled); null = always. + * @param ToolAvailability|null $tools The Tools-tab state, asked at call time: the groups "Divi-Bibliothek" and + * "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. */ public function __construct( private ContentSchema $schema, private ChangeLog $log, - ?Closure $libraryWritable = null, - ?Closure $libraryVisible = null, - private ?Closure $diviWritable = null, - private ?Closure $libraryInsertable = null, - ?Closure $themeBuilderWritable = null, - ?Closure $themeBuilderVisible = null + private ?ToolAvailability $tools = null ) { $this->modules = new DiviModules(); $this->design = new DesignSystemReader(); $this->designWriter = new DesignSystemWriter(); + $this->diviWritable = $this->gate(static fn (ToolAvailability $t): bool => $t->allows('lit/divi-insert')); + $this->libraryInsertable = $this->gate( + static fn (ToolAvailability $t): bool => $t->allows('lit/library-insert') + ); $roles = new DiviRoles(); - $this->access = new LibraryAccess(new DiviLibraryStore(), $roles, $libraryWritable, $libraryVisible); + $this->access = new LibraryAccess( + new DiviLibraryStore(), + $roles, + $this->gate(static fn (ToolAvailability $t): bool => $t->groupWritable(LibraryAccess::GROUP)), + $this->gate(static fn (ToolAvailability $t): bool => $t->groupEnabled(LibraryAccess::GROUP)) + ); $this->themeBuilderAccess = new ThemeBuilderAccess( new DiviThemeBuilder(), $roles, - $themeBuilderWritable, - $themeBuilderVisible + $this->gate(static fn (ToolAvailability $t): bool => $t->groupWritable(ThemeBuilderAccess::GROUP)), + $this->gate(static fn (ToolAvailability $t): bool => $t->groupEnabled(ThemeBuilderAccess::GROUP)) ); } @@ -181,20 +184,12 @@ final class DiviIntegration implements Integration, Bootable } /** - * Divi short form for lit/content-create; available() reports whether Divi 5 is loaded, - * writable() asks $writable (the Tools-tab gate, security M2). - * - * @param (Closure(): bool)|null $writable + * Divi short form for lit/content-create; available() reports whether Divi 5 is loaded, writable() follows + * lit/divi-insert's Tools-tab state (security M2). */ - public function contentComposer(?Closure $writable = null): ContentComposer + public function contentComposer(): ContentComposer { - return new DiviContentComposer( - $this->schema, - $this->composition(), - new DiviSaver(), - $this->design, - $writable - ); + return $this->shortForm($this->diviWritable); } /** @@ -211,7 +206,7 @@ final class DiviIntegration implements Integration, Bootable // Spec §6: the admin check runs as a REST request (same context as MCP calls). Its short form has no // Tools-tab gate: the check tests Divi, not this site's tool switches. - $check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->contentComposer()); + $check = fn (): CompatibilityCheck => CompatibilityCheck::create($this->shortForm(null)); (new DiviCheckRoute($check))->register(); DiviCheckCommand::register($check); @@ -318,6 +313,26 @@ final class DiviIntegration implements Integration, Bootable ); } + /** + * A gate on the Tools-tab state for the classes that take a (Closure(): bool)|null: asked at call time, null + * without $tools (always allowed). + * + * @param Closure(ToolAvailability): bool $ask + * @return (Closure(): bool)|null + */ + private function gate(Closure $ask): ?Closure + { + $tools = $this->tools; + + return $tools === null ? null : static fn (): bool => $ask($tools); + } + + /** @param (Closure(): bool)|null $writable */ + private function shortForm(?Closure $writable): DiviContentComposer + { + return new DiviContentComposer($this->schema, $this->composition(), new DiviSaver(), $this->design, $writable); + } + /** * The one wiring of the composition for every tool that composes (spec D7): presets and Divi's option groups come * from the design system writer, so a stack or group preset in any write tool resolves against the live book. diff --git a/src/Plugin.php b/src/Plugin.php index 174c78d..00c7fe0 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -6,6 +6,7 @@ namespace LIT\MCP; use LIT\MCP\Abilities\AbilityRegistrar; use LIT\MCP\Abilities\ExposurePolicy; +use LIT\MCP\Abilities\ToolAvailability; use LIT\MCP\Admin\AccessTab; use LIT\MCP\Admin\AdminNotices; use LIT\MCP\Admin\ConnectionRepository; @@ -23,9 +24,7 @@ use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\Guides\InstructionsBuilder; use LIT\MCP\Integrations\Core\CoreIntegration; use LIT\MCP\Integrations\Divi\DiviIntegration; -use LIT\MCP\Integrations\Divi\Library\LibraryAccess; use LIT\MCP\Integrations\Divi\Release\DiviToolGate; -use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess; use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\Media\MediaIntegration; use LIT\MCP\Integrations\Pods\PodsFieldSource; @@ -74,66 +73,14 @@ final class Plugin (new ChangeLogCleanup($changeLog, $limits))->register(); $schema = self::contentSchema(); - $registry = null; - $policy = null; - // Ruling P9: with the group "Divi-Bibliothek" disabled, the Divi tools do not see library entries. - $libraryVisible = static function () use (&$policy): bool { - return $policy instanceof ExposurePolicy && $policy->groupEnabled(LibraryAccess::GROUP); - }; - // Security M2: content-create's divi input follows lit/divi-insert (asked at execute time, - // when registry and policy exist), so the Divi group's switches stop every Divi layout write. - $diviWritable = static function () use (&$registry, &$policy): bool { - return $registry instanceof IntegrationRegistry - && $policy instanceof ExposurePolicy - && $policy->allows('lit/divi-insert', $registry->abilities()); - }; - // The Divi tools write to library entries only while the group "Divi-Bibliothek" may write (E9). - $libraryWritable = static function () use (&$policy): bool { - return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP); - }; - // Audit L5: lit/divi-node-copy from a library entry follows lit/library-insert's Tools-tab state. - $libraryInsertable = static function () use (&$registry, &$policy): bool { - return $registry instanceof IntegrationRegistry - && $policy instanceof ExposurePolicy - && $policy->allows('lit/library-insert', $registry->abilities()); - }; - // Etappe 3b: the group "Divi Theme Builder" gates TB layouts in the Divi tools like the library group (E9). - $themeBuilderVisible = static function () use (&$policy): bool { - return $policy instanceof ExposurePolicy && $policy->groupEnabled(ThemeBuilderAccess::GROUP); - }; - $themeBuilderWritable = static function () use (&$policy): bool { - return $policy instanceof ExposurePolicy && $policy->groupWritable(ThemeBuilderAccess::GROUP); - }; - $divi = new DiviIntegration( - $schema, - $changeLog, - $libraryWritable, - libraryVisible: $libraryVisible, - diviWritable: $diviWritable, - libraryInsertable: $libraryInsertable, - themeBuilderWritable: $themeBuilderWritable, - themeBuilderVisible: $themeBuilderVisible - ); - $registry = new IntegrationRegistry( - new CoreIntegration(self::pluginDir() . '/docs/guides', [$divi->environmentComponent()]), - new WordPressIntegration( - $schema, - $changeLog, - $divi->contentComposer($diviWritable), - $divi->libraryIdsVisible(...), - $divi->builderDetector() - ), - new MediaIntegration($schema, $changeLog, $limits, $divi->mediaUsageSources()), - $divi, - $divi->library(), - $divi->themeBuilder() - ); - + $tools = new ToolAvailability(); + $registry = self::integrations($schema, $changeLog, $limits, $tools); + $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); + $tools->bind($registry, $policy); // Integration-owned hooks (Divi: upgrader hook, check route, CLI command, tab "Divi") – before the settings // page reads its tabs. $registry->boot(); - $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); (new AbilityRegistrar( $registry, $policy, @@ -172,6 +119,36 @@ final class Plugin \WPMedia\MCP\OAuth\Bootstrap::instance(); } + /** + * The integrations in registry order – the order of the Tools tab, the guide and the server instructions. The + * Divi integration and its groups share $tools, which Plugin binds once registry and policy exist. + * + * @internal Public for PluginTest only. + */ + public static function integrations( + ContentSchema $schema, + ChangeLog $changeLog, + Limits $limits, + ToolAvailability $tools + ): IntegrationRegistry { + $divi = new DiviIntegration($schema, $changeLog, $tools); + + return new IntegrationRegistry( + new CoreIntegration(self::pluginDir() . '/docs/guides', [$divi->environmentComponent()]), + new WordPressIntegration( + $schema, + $changeLog, + $divi->contentComposer(), + $divi->libraryIdsVisible(...), + $divi->builderDetector() + ), + new MediaIntegration($schema, $changeLog, $limits, $divi->mediaUsageSources()), + $divi, + $divi->library(), + $divi->themeBuilder() + ); + } + public static function settingsPage(): ?SettingsPage { return self::$settingsPage; diff --git a/tests/Unit/Abilities/ToolAvailabilityTest.php b/tests/Unit/Abilities/ToolAvailabilityTest.php new file mode 100644 index 0000000..1522ec8 --- /dev/null +++ b/tests/Unit/Abilities/ToolAvailabilityTest.php @@ -0,0 +1,104 @@ +allows('name')->andReturn($name); + $ability->allows('isDirect')->andReturn(false); + + return $ability; + }, $names); + $integration = Mockery::mock(Integration::class); + $integration->allows('id')->andReturn('divi'); + $integration->allows('isAvailable')->andReturn(true); + $integration->allows('abilities')->andReturn($abilities); + + return new IntegrationRegistry($integration); + } + + /** The temporal coupling of the former use-by-reference closures: they answered false until Plugin set $policy. */ + public function testAQuestionBeforeBindIsAWiringErrorNotFalse(): void + { + $tools = new ToolAvailability(); + + foreach ([ + static fn (): bool => $tools->groupEnabled('divi-library'), + static fn (): bool => $tools->groupWritable('divi-library'), + static fn (): bool => $tools->allows('lit/divi-insert'), + ] as $ask) { + try { + $ask(); + self::fail('answered before bind()'); + } catch (LogicException $e) { + self::assertSame('ToolAvailability asked before Plugin bound it.', $e->getMessage()); + } + } + } + + /** A gate built before bind() (as DiviIntegration builds its gates) answers from the policy bound later. */ + public function testAGateBuiltBeforeBindResolvesLazily(): void + { + $tools = new ToolAvailability(); + $libraryVisible = static fn (): bool => $tools->groupEnabled('divi-library'); + $libraryWritable = static fn (): bool => $tools->groupWritable('divi-library'); + $diviWritable = static fn (): bool => $tools->allows('lit/divi-insert'); + + $tools->bind(self::registry('lit/divi-insert'), new ExposurePolicy(['@readonly:divi-library' => true])); + + self::assertTrue($libraryVisible()); + self::assertFalse($libraryWritable()); + self::assertTrue($diviWritable()); + } + + public function testFollowsTheBoundPolicy(): void + { + $tools = new ToolAvailability(); + $tools->bind( + self::registry('lit/divi-insert', 'lit/library-insert'), + new ExposurePolicy([ + '@group:divi-theme-builder' => ExposurePolicy::DISABLED, + 'lit/library-insert' => ExposurePolicy::DISABLED, + ]) + ); + + self::assertFalse($tools->groupEnabled('divi-theme-builder')); + self::assertFalse($tools->groupWritable('divi-theme-builder')); + self::assertTrue($tools->groupEnabled('divi-library')); + self::assertTrue($tools->groupWritable('divi-library')); + self::assertTrue($tools->allows('lit/divi-insert')); + self::assertFalse($tools->allows('lit/library-insert')); + self::assertFalse($tools->allows('lit/unknown'), 'not registered = not allowed (ExposurePolicy::allows)'); + } + + public function testBindsOnlyOnce(): void + { + $tools = new ToolAvailability(); + $tools->bind(self::registry(), new ExposurePolicy()); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('ToolAvailability is already bound.'); + $tools->bind(self::registry(), new ExposurePolicy()); + } +} diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 7ecc1f3..b4d1378 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -8,6 +8,8 @@ use Brain\Monkey\Actions; use Brain\Monkey\Filters; use Brain\Monkey\Functions; use LIT\MCP\Abilities\Ability; +use LIT\MCP\Abilities\ExposurePolicy; +use LIT\MCP\Abilities\ToolAvailability; use LIT\MCP\Admin\AdminTab; use LIT\MCP\Admin\DiviTab; use LIT\MCP\Admin\SettingsPage; @@ -19,6 +21,8 @@ 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\ReleaseResolver; +use LIT\MCP\Integrations\Integration; +use LIT\MCP\Integrations\IntegrationRegistry; use LIT\MCP\Integrations\Media\MediaUsageSource; use LIT\MCP\Integrations\WordPress\BuilderDetector; use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; @@ -183,13 +187,29 @@ final class DiviIntegrationTest extends TestCase self::assertSame(1, $report->blockedHidden); } + /** A Tools-tab state bound to $policy, with lit/divi-insert registered (the Divi group available). */ + private static function tools(ExposurePolicy $policy = new ExposurePolicy()): ToolAvailability + { + $insert = Mockery::mock(Ability::class); + $insert->allows('name')->andReturn('lit/divi-insert'); + $insert->allows('isDirect')->andReturn(true); + $divi = Mockery::mock(Integration::class); + $divi->allows('id')->andReturn('divi'); + $divi->allows('isAvailable')->andReturn(true); + $divi->allows('abilities')->andReturn([$insert]); + $tools = new ToolAvailability(); + $tools->bind(new IntegrationRegistry($divi), $policy); + + return $tools; + } + /** Ruling P9 (E9): lit/divi-node-copy follows lit/divi-insert's Tools-tab state. */ public function testNodeCopyFollowsLitDiviInsert(): void { $integration = new DiviIntegration( Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden(), - diviWritable: static fn (): bool => false + self::tools(new ExposurePolicy(['lit/divi-insert' => ExposurePolicy::DISABLED])) ); $copy = null; foreach ($integration->abilities() as $ability) { @@ -202,6 +222,22 @@ final class DiviIntegrationTest extends TestCase self::assertToolError($copy->permission(['post_id' => 7, 'source_post_id' => 8]), 'unsupported'); } + /** Security M2: content-create's divi input follows lit/divi-insert, asked when content-create asks. */ + public function testTheShortFormFollowsLitDiviInsert(): void + { + $schema = Mockery::mock(ContentSchema::class); + $open = new DiviIntegration($schema, FakeChangeLog::forbidden(), self::tools()); + $closed = new DiviIntegration( + $schema, + FakeChangeLog::forbidden(), + self::tools(new ExposurePolicy(['lit/divi-insert' => ExposurePolicy::DISABLED])) + ); + + self::assertTrue($open->contentComposer()->writable()); + self::assertFalse($closed->contentComposer()->writable()); + self::assertTrue((new DiviIntegration($schema, FakeChangeLog::forbidden()))->contentComposer()->writable()); + } + public function testGivesContentCreateTheDiviShortForm(): void { $integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()); @@ -221,21 +257,22 @@ final class DiviIntegrationTest extends TestCase $outline = static fn (DiviIntegration $divi): mixed => $divi->abilities()[0]->permission(['post_id' => 1055]); // No Divi in unit tests: DiviRoles allows nothing, so the entry is not found either way (ruling P12); - // the wiring shows in which gate the integration asks. - $asked = 0; - $visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), null, static function () use (&$asked): bool { - ++$asked; - - return true; - }); + // the wiring shows in which gate the integration asks: an unbound ToolAvailability throws when asked. + $visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), self::tools()); self::assertToolError($outline($visible), 'not_found'); - self::assertSame(1, $asked); self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'not_found'); - $hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static fn (): bool => false); + $hidden = new DiviIntegration( + $schema, + FakeChangeLog::forbidden(), + self::tools(new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED])) + ); $refusal = $outline($hidden); self::assertInstanceOf(WP_Error::class, $refusal); self::assertToolError($refusal, 'not_found'); + + $this->expectExceptionMessage('ToolAvailability asked before Plugin bound it.'); + $outline(new DiviIntegration($schema, FakeChangeLog::forbidden(), new ToolAvailability())); } /** @@ -247,16 +284,16 @@ final class DiviIntegrationTest extends TestCase { LibraryFixtures::given([]); $schema = Mockery::mock(ContentSchema::class); - $asked = 0; - $hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static function () use (&$asked): bool { - ++$asked; - - return false; - }); + $hidden = new DiviIntegration( + $schema, + FakeChangeLog::forbidden(), + self::tools(new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED])) + ); self::assertFalse($hidden->libraryIdsVisible()); - self::assertSame(1, $asked); self::assertFalse((new DiviIntegration($schema, FakeChangeLog::forbidden()))->libraryIdsVisible()); + $this->expectExceptionMessage('ToolAvailability asked before Plugin bound it.'); + (new DiviIntegration($schema, FakeChangeLog::forbidden(), new ToolAvailability()))->libraryIdsVisible(); } /** Bootable: Divi's own hooks – upgrader cache reset, check route, tab "Divi" after the plugin's tabs. */ diff --git a/tests/Unit/PluginTest.php b/tests/Unit/PluginTest.php new file mode 100644 index 0000000..acd54e6 --- /dev/null +++ b/tests/Unit/PluginTest.php @@ -0,0 +1,71 @@ +ids() + ); + } + + /** Only the Divi integration has boot steps; a second boot registers nothing twice. */ + public function testTheBootableIntegrationsBootExactlyOnce(): void + { + Actions\expectAdded('upgrader_process_complete')->once(); + Actions\expectAdded('rest_api_init')->once(); + Filters\expectAdded(SettingsPage::TABS_FILTER)->once(); + $registry = self::registry(); + + $registry->boot(); + $registry->boot(); + } + + /** + * No temporal coupling: building and booting the integrations never asks the Tools-tab state (an unbound + * ToolAvailability throws when asked); bound afterwards, it answers from the bound policy. + */ + public function testTheToolsTabStateIsAskedOnlyAfterBind(): void + { + Functions\stubTranslationFunctions(); + $tools = new ToolAvailability(); + $registry = self::registry($tools); + $registry->boot(); + + $tools->bind($registry, new ExposurePolicy(['@group:divi-library' => ExposurePolicy::DISABLED])); + + self::assertFalse($tools->groupEnabled('divi-library')); + self::assertTrue($tools->groupWritable('divi-theme-builder')); + self::assertFalse($tools->allows('lit/divi-insert'), 'no Divi in unit tests: lit/divi-insert not registered'); + } +}