diff --git a/src/Abilities/ExposurePolicy.php b/src/Abilities/ExposurePolicy.php index 3f5443b..1db96b8 100644 --- a/src/Abilities/ExposurePolicy.php +++ b/src/Abilities/ExposurePolicy.php @@ -7,6 +7,12 @@ namespace LIT\MCP\Abilities; /** * Decides per ability whether it is a direct MCP tool, only reachable through the * adapter's meta-tools, or disabled (spec §4.3). Ability::isDirect() is the default. + * + * Abilities are grouped by the integration they come from. Resolution: tool override › + * group override › tool default. A group's "nur lesen" switch disables every ability of the + * group that is not annotated readonly, whatever the modes say. REQUIRED abilities always + * keep their default. Group entries live in the same option under keys that start with "@" + * (ability names always contain "/", so they cannot collide). */ final class ExposurePolicy { @@ -16,6 +22,12 @@ final class ExposurePolicy public const DISABLED = 'disabled'; public const MODES = [self::DIRECT, self::INDIRECT, self::DISABLED]; + /** Option key prefix of a group's mode: "@group:" => mode. */ + public const GROUP_PREFIX = '@group:'; + + /** Option key prefix of a group's read-only switch: "@readonly:" => true. */ + public const READONLY_PREFIX = '@readonly:'; + /** * Image/embedded-resource results only work for direct tools (ruling Q6 / task 6 * dispatch): lit/media-preview (ImageContent) and lit/media-download (embedded @@ -26,9 +38,41 @@ final class ExposurePolicy /** Entry point for every conversation – must always stay a direct tool. */ public const REQUIRED = ['lit/guide']; - /** @param array $overrides Ability name => mode. */ - public function __construct(private array $overrides = []) + /** @var array Ability name => mode. */ + private array $tools = []; + + /** @var array Group id => mode. */ + private array $groupModes = []; + + /** @var array Group ids switched to "nur lesen". */ + private array $readonlyGroups = []; + + /** @var array|null Ability name => group id; null until resolved. */ + private ?array $groups = []; + + /** @var (callable(): array)|null Lazy source of $groups. */ + private $groupSource = null; + + /** + * @param array $overrides Option content: ability name => mode, plus + * "@group:" => mode and "@readonly:" => true. + */ + public function __construct(array $overrides = []) { + foreach ($overrides as $key => $value) { + $key = (string) $key; + if (str_starts_with($key, self::GROUP_PREFIX)) { + if (is_string($value) && in_array($value, self::MODES, true)) { + $this->groupModes[substr($key, strlen(self::GROUP_PREFIX))] = $value; + } + } elseif (str_starts_with($key, self::READONLY_PREFIX)) { + if ($value === true) { + $this->readonlyGroups[substr($key, strlen(self::READONLY_PREFIX))] = true; + } + } elseif (is_string($value)) { + $this->tools[$key] = $value; + } + } } public static function fromOption(): self @@ -36,11 +80,98 @@ final class ExposurePolicy return new self(self::sanitize(get_option(self::OPTION, []))); } + /** + * Copy of this policy that knows which group (integration id) each ability belongs to. + * Without it, group entries have no effect. A callable is resolved on first use – boot runs + * on plugins_loaded, before the theme (Divi) is loaded, so the map must not be built there. + * + * @param array|(callable(): array) $groups Ability name => group id. + */ + public function withGroups(array|callable $groups): self + { + $clone = clone $this; + if (is_array($groups)) { + $clone->groups = $groups; + $clone->groupSource = null; + } else { + $clone->groups = null; + $clone->groupSource = $groups; + } + + return $clone; + } + public function mode(Ability $ability): string { - $mode = $this->overrides[$ability->name()] ?? null; + if (in_array($ability->name(), self::REQUIRED, true)) { + return $this->defaultMode($ability); + } - return is_string($mode) && self::isAllowed($ability->name(), $mode) ? $mode : $this->defaultMode($ability); + if ($this->disabledByReadonly($ability)) { + return self::DISABLED; + } + + return $this->toolOverride($ability->name()) ?? $this->inheritedMode($ability); + } + + /** Mode the ability gets without an override of its own: group override › tool default. */ + public function inheritedMode(Ability $ability): string + { + $name = $ability->name(); + $groupMode = $this->groupModeOf($name); + if ($groupMode === null || in_array($name, self::REQUIRED, true)) { + return $this->defaultMode($ability); + } + + return self::isAllowed($name, $groupMode) ? $groupMode : self::DIRECT; + } + + /** True if the group's mode is not allowed for this ability and it falls back to "direct". */ + public function groupModeAdjusted(Ability $ability): bool + { + $name = $ability->name(); + $groupMode = $this->groupModeOf($name); + + return $groupMode !== null + && ! in_array($name, self::REQUIRED, true) + && ! self::isAllowed($name, $groupMode); + } + + /** True if the group's "nur lesen" switch disables this (writing) ability. */ + public function disabledByReadonly(Ability $ability): bool + { + $group = $this->groupOf($ability->name()); + if ($group === null || ! isset($this->readonlyGroups[$group])) { + return false; + } + + if (in_array($ability->name(), self::REQUIRED, true)) { + return false; + } + + return ($ability->annotations()['readonly'] ?? false) !== true; + } + + public function hasToolOverride(string $name): bool + { + return $this->toolOverride($name) !== null; + } + + public function toolOverride(string $name): ?string + { + $mode = $this->tools[$name] ?? null; + + return $mode !== null && self::isAllowed($name, $mode) ? $mode : null; + } + + public function groupMode(string $group): ?string + { + return $this->groupModes[$group] ?? null; + } + + public function isReadonly(string $group): bool + { + return isset($this->readonlyGroups[$group]); } public function defaultMode(Ability $ability): string @@ -94,29 +225,76 @@ final class ExposurePolicy } /** - * Settings API sanitize callback for the option. + * Settings API sanitize callback for the option. Keeps valid ability entries (name => allowed + * mode), "@group:" => mode and "@readonly:" => true; drops everything else. With + * $groupIds, only those group ids are accepted. * - * @return array + * @param list|null $groupIds Known group (integration) ids; null = any well-formed id. + * @return array */ - public static function sanitize(mixed $input): array + public static function sanitize(mixed $input, ?array $groupIds = null): array { if (! is_array($input)) { return []; } $clean = []; - foreach ($input as $name => $mode) { - if ( - is_string($name) - && is_string($mode) - && preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $name) === 1 - && self::isAllowed($name, $mode) + foreach ($input as $key => $value) { + if (! is_string($key)) { + continue; + } + + if (str_starts_with($key, self::GROUP_PREFIX)) { + if ( + self::isGroupId(substr($key, strlen(self::GROUP_PREFIX)), $groupIds) + && is_string($value) + && in_array($value, self::MODES, true) + ) { + $clean[$key] = $value; + } + } elseif (str_starts_with($key, self::READONLY_PREFIX)) { + if ( + self::isGroupId(substr($key, strlen(self::READONLY_PREFIX)), $groupIds) + && ($value === true || $value === '1' || $value === 1) + ) { + $clean[$key] = true; + } + } elseif ( + is_string($value) + && preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $key) === 1 + && self::isAllowed($key, $value) ) { - $clean[$name] = $mode; + $clean[$key] = $value; } } ksort($clean); return $clean; } + + /** @param list|null $groupIds */ + private static function isGroupId(string $id, ?array $groupIds): bool + { + if (preg_match('#^[a-z0-9-]+$#', $id) !== 1) { + return false; + } + + return $groupIds === null || in_array($id, $groupIds, true); + } + + private function groupOf(string $name): ?string + { + if ($this->groups === null) { + $this->groups = $this->groupSource !== null ? ($this->groupSource)() : []; + } + + return $this->groups[$name] ?? null; + } + + private function groupModeOf(string $name): ?string + { + $group = $this->groupOf($name); + + return $group === null ? null : ($this->groupModes[$group] ?? null); + } } diff --git a/src/Admin/ToolsTab.php b/src/Admin/ToolsTab.php index 00b7aa4..c18b264 100644 --- a/src/Admin/ToolsTab.php +++ b/src/Admin/ToolsTab.php @@ -4,18 +4,24 @@ declare(strict_types=1); namespace LIT\MCP\Admin; +use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\Integrations\IntegrationRegistry; /** - * Tab "Tools": direct/indirect/disabled exposure per ability (spec §8.4). Only the modes - * ExposurePolicy::isAllowed() allows for a given ability are offered; "Standard" saves nothing, - * so a later version's changed default takes effect automatically. + * Tab "Tools": direct/indirect/disabled exposure per ability (spec §8.4), grouped by the + * integration the ability comes from. Each group has its own mode (inherited by its tools) + * and a "nur lesen" switch. Only the modes ExposurePolicy::isAllowed() allows for a given + * ability are offered; "Standard"/"wie Gruppe" saves nothing, so a later version's changed + * default takes effect automatically. */ final class ToolsTab implements AdminTab { public const GROUP = 'lit_mcp_tools'; + /** More direct tools than this cost noticeable context in every claude.ai conversation. */ + public const DIRECT_WARNING_THRESHOLD = 25; + public function __construct(private IntegrationRegistry $registry, private ExposurePolicy $policy) { } @@ -34,11 +40,21 @@ final class ToolsTab implements AdminTab { register_setting(self::GROUP, ExposurePolicy::OPTION, [ 'type' => 'array', - 'sanitize_callback' => [ExposurePolicy::class, 'sanitize'], + 'sanitize_callback' => [$this, 'sanitize'], 'default' => [], ]); } + /** + * Sanitize callback: ExposurePolicy::sanitize() restricted to the known group ids. + * + * @return array + */ + public function sanitize(mixed $input): array + { + return ExposurePolicy::sanitize($input, $this->registry->ids()); + } + public function registerActions(): void { } @@ -64,50 +80,169 @@ final class ToolsTab implements AdminTab 'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.', 'wp-lit-mcp' ) . '

'; + $policy = $this->policy->withGroups($this->registry->groupMap()); + $this->renderCounter(count($policy->directNames($this->registry->abilities()))); + echo '
'; settings_fields(self::GROUP); - echo ''; - - foreach ($this->registry->abilities() as $ability) { - $name = $ability->name(); - $current = $this->policy->mode($ability); - $isDefault = $current === $this->policy->defaultMode($ability); - printf( - ''; + foreach ($this->registry->grouped() as $group => $abilities) { + $this->renderGroup($policy, $group, $abilities); } - - echo '
' . esc_html__('Tool', 'wp-lit-mcp') . '' - . esc_html__('Einstellung', 'wp-lit-mcp') . '
%s
%s
'; submit_button(); echo '
'; } + private function renderCounter(int $direct): void + { + echo '

' . esc_html(sprintf( + /* translators: %d: number of direct tools */ + _n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'), + $direct + )) . '

'; + + if ($direct > self::DIRECT_WARNING_THRESHOLD) { + echo '

' . esc_html__('Achtung:', 'wp-lit-mcp') . ' ' + . esc_html__( + 'Viele direkte Tools kosten in claude.ai in jeder Unterhaltung Kontext.', + 'wp-lit-mcp' + ) . ' ' + . esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp') + . '

'; + } + } + + /** @param list $abilities */ + private function renderGroup(ExposurePolicy $policy, string $group, array $abilities): void + { + $count = count($abilities); + $mixed = false; + foreach ($abilities as $ability) { + $mixed = $mixed || $policy->hasToolOverride($ability->name()); + } + + printf( + '
%s', + esc_html(sprintf( + /* translators: 1: group label, 2: number of tools */ + _n('%1$s (%2$d Tool)', '%1$s (%2$d Tools)', $count, 'wp-lit-mcp'), + $this->groupLabel($group), + $count + )) + ); + + $groupMode = $policy->groupMode($group); + printf( + '

'; + printf( + '', + esc_attr(ExposurePolicy::OPTION), + esc_attr(ExposurePolicy::READONLY_PREFIX . $group), + checked($policy->isReadonly($group), true, false), + esc_html__('nur lesen (schreibende Tools dieser Gruppe aus)', 'wp-lit-mcp') + ); + if ($mixed) { + echo ' ' . esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp') . ''; + } + echo '

'; + + echo ''; + foreach ($abilities as $ability) { + $this->renderTool($policy, $ability, $groupMode !== null); + } + echo '
' . esc_html__('Tool', 'wp-lit-mcp') . '' + . esc_html__('Einstellung', 'wp-lit-mcp') . '
'; + } + + private function renderTool(ExposurePolicy $policy, Ability $ability, bool $groupOverride): void + { + $name = $ability->name(); + $own = $policy->toolOverride($name); + $locked = $policy->disabledByReadonly($ability); + $required = in_array($name, ExposurePolicy::REQUIRED, true); + + printf( + '%s
%s'; + + if ($locked && $own !== null) { + // A disabled select is not submitted – keep the tool's own setting for later. + printf( + '', + esc_attr(ExposurePolicy::OPTION), + esc_attr($name), + esc_attr($own) + ); + } + + $note = match (true) { + $locked => __('durch ‚nur lesen‘ aus', 'wp-lit-mcp'), + $own === null && $policy->groupModeAdjusted($ability) + => __('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp'), + default => '', + }; + if ($note !== '') { + echo ' ' . esc_html($note) . ''; + } + echo ''; + } + + private function groupLabel(string $group): string + { + return match ($group) { + 'core' => __('Grundlagen', 'wp-lit-mcp'), + 'wordpress' => __('WordPress', 'wp-lit-mcp'), + 'media' => __('Medien', 'wp-lit-mcp'), + 'pods' => __('Pods', 'wp-lit-mcp'), + 'divi' => __('Divi', 'wp-lit-mcp'), + default => $group, + }; + } + private function modeLabel(string $mode): string { return match ($mode) { diff --git a/src/Integrations/IntegrationRegistry.php b/src/Integrations/IntegrationRegistry.php index d9f7341..68345e7 100644 --- a/src/Integrations/IntegrationRegistry.php +++ b/src/Integrations/IntegrationRegistry.php @@ -11,8 +11,8 @@ final class IntegrationRegistry /** @var list */ private array $integrations; - /** @var list|null */ - private ?array $abilities = null; + /** @var list}>|null Available integrations with their abilities. */ + private ?array $loaded = null; public function __construct(Integration ...$integrations) { @@ -31,15 +31,61 @@ final class IntegrationRegistry /** @return list */ public function abilities(): array { - if ($this->abilities === null) { - $abilities = []; - foreach ($this->available() as $integration) { - array_push($abilities, ...$integration->abilities()); - } - - $this->abilities = $abilities; + $abilities = []; + foreach ($this->loaded() as [, $own]) { + array_push($abilities, ...$own); } - return $this->abilities; + return $abilities; + } + + /** + * Abilities of the available integrations, grouped by integration id (registry order). + * + * @return array> + */ + public function grouped(): array + { + $groups = []; + foreach ($this->loaded() as [$integration, $own]) { + $groups[$integration->id()] = array_merge($groups[$integration->id()] ?? [], $own); + } + + return $groups; + } + + /** @return array Ability name => integration id (the ability's group). */ + public function groupMap(): array + { + $map = []; + foreach ($this->grouped() as $id => $abilities) { + foreach ($abilities as $ability) { + $map[$ability->name()] = $id; + } + } + + return $map; + } + + /** @return list Ids of all known integrations, available or not. */ + public function ids(): array + { + return array_values(array_unique(array_map( + static fn (Integration $integration): string => $integration->id(), + $this->integrations + ))); + } + + /** @return list}> */ + private function loaded(): array + { + if ($this->loaded === null) { + $this->loaded = array_map( + static fn (Integration $integration): array => [$integration, $integration->abilities()], + $this->available() + ); + } + + return $this->loaded; } } diff --git a/src/Plugin.php b/src/Plugin.php index c1fad1e..162fd13 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -73,7 +73,7 @@ final class Plugin new DiviIntegration($schema, $changeLog) ); - $policy = ExposurePolicy::fromOption(); + $policy = ExposurePolicy::fromOption()->withGroups($registry->groupMap(...)); (new AbilityRegistrar($registry, $policy))->register(); (new ServerRegistrar( $registry, diff --git a/tests/Unit/Abilities/AbilityRegistrarTest.php b/tests/Unit/Abilities/AbilityRegistrarTest.php index 6b96417..4837b58 100644 --- a/tests/Unit/Abilities/AbilityRegistrarTest.php +++ b/tests/Unit/Abilities/AbilityRegistrarTest.php @@ -217,4 +217,18 @@ final class AbilityRegistrarTest extends TestCase (new AbilityRegistrar(new IntegrationRegistry($integration), new ExposurePolicy(['lit/test' => 'disabled']))) ->registerAbilities(); } + + public function testGroupDisabledAbilitiesAreNotRegistered(): void + { + $ability = $this->ability(); + $integration = Mockery::mock(Integration::class); + $integration->allows('id')->andReturn('divi'); + $integration->allows('isAvailable')->andReturn(true); + $integration->allows('abilities')->andReturn([$ability]); + $registry = new IntegrationRegistry($integration); + Functions\expect('wp_register_ability')->never(); + + (new AbilityRegistrar($registry, (new ExposurePolicy(['@group:divi' => 'disabled']))->withGroups($registry->groupMap()))) + ->registerAbilities(); + } } diff --git a/tests/Unit/Abilities/ExposurePolicyTest.php b/tests/Unit/Abilities/ExposurePolicyTest.php index e889cdb..d68f258 100644 --- a/tests/Unit/Abilities/ExposurePolicyTest.php +++ b/tests/Unit/Abilities/ExposurePolicyTest.php @@ -12,11 +12,15 @@ use Mockery; final class ExposurePolicyTest extends TestCase { - private function ability(string $name, bool $direct): Ability + /** @param array|null $annotations */ + private function ability(string $name, bool $direct, ?array $annotations = null): Ability { $ability = Mockery::mock(Ability::class); $ability->allows('name')->andReturn($name); $ability->allows('isDirect')->andReturn($direct); + if ($annotations !== null) { + $ability->allows('annotations')->andReturn($annotations); + } return $ability; } @@ -113,4 +117,155 @@ final class ExposurePolicyTest extends TestCase self::assertSame('direct', ExposurePolicy::fromOption()->mode($this->ability('lit/a', true))); } + + private const GROUPS = [ + 'lit/guide' => 'core', + 'lit/site-status' => 'core', + 'lit/divi-outline' => 'divi', + 'lit/divi-update' => 'divi', + 'lit/divi-delete' => 'divi', + 'lit/media-preview' => 'media', + 'lit/media-download' => 'media', + 'lit/media-get' => 'media', + ]; + + public function testToolOverrideBeatsGroupOverrideBeatsToolDefault(): void + { + $policy = (new ExposurePolicy([ + '@group:divi' => 'indirect', + 'lit/divi-update' => 'direct', + ]))->withGroups(self::GROUPS); + + self::assertSame('direct', $policy->mode($this->ability('lit/divi-update', false))); + self::assertSame('indirect', $policy->mode($this->ability('lit/divi-outline', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/site-status', true))); + self::assertTrue($policy->hasToolOverride('lit/divi-update')); + self::assertFalse($policy->hasToolOverride('lit/divi-outline')); + self::assertSame('indirect', $policy->groupMode('divi')); + self::assertNull($policy->groupMode('core')); + self::assertSame('indirect', $policy->inheritedMode($this->ability('lit/divi-update', false))); + } + + public function testGroupSettingsAreIgnoredWithoutAGroupMap(): void + { + $policy = new ExposurePolicy(['@group:divi' => 'disabled', '@readonly:divi' => true]); + + self::assertSame('direct', $policy->mode($this->ability('lit/divi-outline', true))); + } + + public function testGuideKeepsItsDefaultWhateverTheGroupSays(): void + { + $policy = (new ExposurePolicy(['@group:core' => 'disabled', '@readonly:core' => true])) + ->withGroups(self::GROUPS); + + self::assertSame('direct', $policy->mode($this->ability('lit/guide', true, []))); + self::assertSame('disabled', $policy->mode($this->ability('lit/site-status', true, ['readonly' => true]))); + } + + public function testDirectOnlyToolsTakeTheNearestAllowedModeWhenTheGroupSaysIndirect(): void + { + $policy = (new ExposurePolicy(['@group:media' => 'indirect', 'lit/media-download' => 'disabled'])) + ->withGroups(self::GROUPS); + + $preview = $this->ability('lit/media-preview', true); + self::assertSame('direct', $policy->mode($preview)); + self::assertTrue($policy->groupModeAdjusted($preview)); + self::assertSame('disabled', $policy->mode($this->ability('lit/media-download', true))); + self::assertSame('indirect', $policy->mode($this->ability('lit/media-get', true))); + self::assertFalse($policy->groupModeAdjusted($this->ability('lit/media-get', true))); + } + + public function testReadonlyGroupDisablesWriteToolsEvenAgainstAToolOverride(): void + { + $policy = (new ExposurePolicy([ + '@readonly:divi' => true, + '@group:divi' => 'direct', + 'lit/divi-update' => 'direct', + 'lit/divi-outline' => 'indirect', + ]))->withGroups(self::GROUPS); + + $update = $this->ability('lit/divi-update', false, ['readonly' => false, 'destructive' => false]); + $delete = $this->ability('lit/divi-delete', false, ['destructive' => true]); + $outline = $this->ability('lit/divi-outline', true, ['readonly' => true]); + + self::assertSame('disabled', $policy->mode($update)); + self::assertSame('disabled', $policy->mode($delete)); + self::assertTrue($policy->disabledByReadonly($update)); + self::assertSame('indirect', $policy->mode($outline)); + self::assertFalse($policy->disabledByReadonly($outline)); + self::assertTrue($policy->isReadonly('divi')); + self::assertFalse($policy->isReadonly('media')); + self::assertSame([$outline], $policy->enabled([$update, $delete, $outline])); + self::assertSame([], $policy->directNames([$update, $delete, $outline])); + } + + public function testSanitizeValidatesGroupEntries(): void + { + self::assertSame( + [ + '@group:divi' => 'indirect', + '@readonly:divi' => true, + '@readonly:media' => true, + 'lit/menu-list' => 'disabled', + ], + ExposurePolicy::sanitize([ + 'lit/menu-list' => 'disabled', + '@group:divi' => 'indirect', + '@group:media' => 'sometimes', + '@group:core' => '', + '@group:unknown' => 'direct', + '@group:' => 'direct', + '@group:' => 'direct', + '@readonly:divi' => '1', + '@readonly:media' => true, + '@readonly:core' => 'yes', + '@readonly:pods' => ['1'], + '@readonly:unknown' => '1', + '@other:divi' => 'direct', + '@group:divi/x' => 'direct', + 0 => 'direct', + ], ['core', 'divi', 'media', 'pods']) + ); + } + + public function testSanitizeWithoutKnownGroupsStillChecksTheIdSyntax(): void + { + self::assertSame( + ['@group:divi' => 'disabled', '@readonly:divi' => true], + ExposurePolicy::sanitize(['@group:divi' => 'disabled', '@readonly:divi' => true, '@group:a b' => 'direct']) + ); + } + + public function testOldFlatOptionStaysValid(): void + { + $old = ['lit/content-get' => 'indirect', 'lit/menu-list' => 'direct']; + + self::assertSame($old, ExposurePolicy::sanitize($old, ['core', 'wordpress'])); + } + + public function testFromOptionReadsGroupEntries(): void + { + Functions\when('get_option')->justReturn(['@group:divi' => 'disabled', 'lit/divi-update' => 'indirect']); + + $policy = ExposurePolicy::fromOption()->withGroups(self::GROUPS); + + self::assertSame('disabled', $policy->mode($this->ability('lit/divi-outline', true))); + self::assertSame('indirect', $policy->mode($this->ability('lit/divi-update', false))); + } + + public function testGroupMapCanBeResolvedLazily(): void + { + $calls = 0; + $policy = (new ExposurePolicy(['@group:divi' => 'disabled']))->withGroups( + static function () use (&$calls): array { + $calls++; + return self::GROUPS; + } + ); + + self::assertSame(0, $calls, 'The map must not be built while booting (Divi is not loaded yet).'); + self::assertSame('disabled', $policy->mode($this->ability('lit/divi-outline', true))); + self::assertSame('direct', $policy->mode($this->ability('lit/site-status', true))); + self::assertSame(1, $calls); + } } diff --git a/tests/Unit/Admin/ToolsTabTest.php b/tests/Unit/Admin/ToolsTabTest.php index 9ec0249..09f2cfd 100644 --- a/tests/Unit/Admin/ToolsTabTest.php +++ b/tests/Unit/Admin/ToolsTabTest.php @@ -15,53 +15,219 @@ use Mockery; final class ToolsTabTest extends TestCase { - private function registry(): IntegrationRegistry + /** @param list $abilities name, isDirect, readonly */ + private function integration(string $id, array $abilities, bool $available = true): Integration { - $abilities = []; - foreach ([['lit/guide', true], ['lit/media-preview', true], ['lit/menu-list', false]] as [$name, $direct]) { + $mocks = []; + foreach ($abilities as [$name, $direct, $readonly]) { $ability = Mockery::mock(Ability::class); $ability->allows('name')->andReturn($name); $ability->allows('label')->andReturn('Label ' . $name); $ability->allows('isDirect')->andReturn($direct); - $abilities[] = $ability; + $ability->allows('annotations')->andReturn(['readonly' => $readonly]); + $mocks[] = $ability; } $integration = Mockery::mock(Integration::class); - $integration->allows('isAvailable')->andReturn(true); - $integration->allows('abilities')->andReturn($abilities); + $integration->allows('id')->andReturn($id); + $integration->allows('isAvailable')->andReturn($available); + $integration->allows('abilities')->andReturn($mocks); - return new IntegrationRegistry($integration); + return $integration; } - public function testRegistersTheExposureOptionWithThePolicySanitizer(): void + private function registry(): IntegrationRegistry { - Functions\expect('register_setting')->once()->with( - 'lit_mcp_tools', - 'lit_mcp_exposure', - Mockery::on(static fn (array $args): bool => $args['sanitize_callback'] === [ExposurePolicy::class, 'sanitize']) + return new IntegrationRegistry( + $this->integration('core', [['lit/guide', true, true], ['lit/site-status', true, true]]), + $this->integration('media', [['lit/media-preview', true, true], ['lit/media-upload', false, false]]), + $this->integration('divi', [['lit/divi-outline', true, true], ['lit/divi-update', true, false]]), + $this->integration('pods', [['lit/pods-x', true, true]], false) ); - - (new ToolsTab($this->registry(), new ExposurePolicy()))->registerSettings(); } - public function testRendersOneSelectPerAbilityWithOnlyAllowedModes(): void + /** @param array $option */ + private function render(array $option, ?IntegrationRegistry $registry = null): string { Functions\stubEscapeFunctions(); Functions\stubTranslationFunctions(); Functions\when('settings_fields')->justReturn(null); Functions\when('submit_button')->justReturn(null); - Functions\when('selected')->alias(static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' selected="selected"' : ''); + Functions\when('selected')->alias( + static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' selected="selected"' : '' + ); + Functions\when('checked')->alias( + static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' checked="checked"' : '' + ); ob_start(); - (new ToolsTab($this->registry(), new ExposurePolicy(['lit/menu-list' => 'disabled'])))->render(); - $html = (string) ob_get_clean(); + (new ToolsTab($registry ?? $this->registry(), new ExposurePolicy($option)))->render(); - $selects = array_slice(explode('', $start) - $start); + } + + /** The table row of one tool (from its to ). */ + private function row(string $html, string $name): string + { + $at = strpos($html, '' . $name . ''); + self::assertNotFalse($at, 'No row for ' . $name); + $start = (int) strrpos(substr($html, 0, $at), '', $at) - $start); + } + + /** The
block of one group. */ + private function group(string $html, string $label): string + { + $at = strpos($html, '' . $label); + self::assertNotFalse($at, 'No group ' . $label); + $start = (int) strrpos(substr($html, 0, $at), '', $at) - $start); + } + + public function testRegistersTheExposureOptionWithAGroupAwareSanitizer(): void + { + $tab = new ToolsTab($this->registry(), new ExposurePolicy()); + $callback = null; + Functions\expect('register_setting')->once()->with( + 'lit_mcp_tools', + 'lit_mcp_exposure', + Mockery::on(static function (array $args) use (&$callback): bool { + $callback = $args['sanitize_callback']; + return true; + }) + ); + + $tab->registerSettings(); + + self::assertSame([$tab, 'sanitize'], $callback); + self::assertSame( + ['@group:pods' => 'disabled', '@readonly:divi' => true, 'lit/menu-list' => 'disabled'], + $tab->sanitize([ + '@readonly:divi' => '1', + '@group:pods' => 'disabled', + '@group:shop' => 'direct', + 'lit/menu-list' => 'disabled', + 'lit/guide' => 'disabled', + ]) + ); + } + + public function testGroupsToolsByAvailableIntegrationWithOnlyAllowedModes(): void + { + $html = $this->render(['lit/divi-update' => 'disabled']); + + self::assertSame(3, substr_count($html, '
Grundlagen (2 Tools)', $html); + self::assertStringContainsString('Medien (2 Tools)', $html); + self::assertStringContainsString('Divi (2 Tools)', $html); + self::assertStringNotContainsString('pods', $html); + self::assertStringContainsString('lit/divi-outline', $this->group($html, 'Divi')); + self::assertStringNotContainsString('lit/guide', $this->group($html, 'Divi')); + + $groupSelect = $this->select($html, '@group:divi'); + self::assertStringContainsString('', $groupSelect); + foreach (['direct', 'indirect', 'disabled'] as $mode) { + self::assertStringContainsString('value="' . $mode . '"', $groupSelect); + } + self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1"', $html); + self::assertStringContainsString('nur lesen (schreibende Tools dieser Gruppe aus)', $html); + + $guide = $this->select($html, 'lit/guide'); self::assertStringNotContainsString('value="disabled"', $guide); - self::assertStringNotContainsString('value="indirect"', $preview); - self::assertStringContainsString('value="disabled" selected="selected"', $menu); - self::assertStringContainsString('value="" selected="selected"', $guide); + self::assertStringNotContainsString('value="indirect"', $guide); + self::assertStringContainsString('value="" selected="selected">Standard (direkt)', $guide); + self::assertStringNotContainsString('value="indirect"', $this->select($html, 'lit/media-preview')); + self::assertStringContainsString('value="disabled" selected="selected"', $this->select($html, 'lit/divi-update')); + } + + public function testToolsOfAGroupWithOverrideOfferWieGruppeAndTheGroupSaysGemischt(): void + { + $html = $this->render(['@group:divi' => 'indirect', 'lit/divi-update' => 'direct']); + + self::assertStringContainsString('value="indirect" selected="selected"', $this->select($html, '@group:divi')); + self::assertStringContainsString( + '', + $this->select($html, 'lit/divi-outline') + ); + $update = $this->select($html, 'lit/divi-update'); + self::assertStringContainsString('', $update); + self::assertStringContainsString('value="direct" selected="selected"', $update); + self::assertStringContainsString('gemischt', $this->group($html, 'Divi')); + self::assertStringNotContainsString('gemischt', $this->group($html, 'Medien')); + self::assertStringContainsString('Standard (direkt)', $this->select($html, 'lit/site-status')); + } + + public function testDirectOnlyToolsExplainThatIndirectDoesNotApply(): void + { + $html = $this->render(['@group:media' => 'indirect']); + + self::assertStringContainsString('wie Gruppe (direkt)', $this->select($html, 'lit/media-preview')); + self::assertStringContainsString('„indirekt“ geht hier nicht', $this->row($html, 'lit/media-preview')); + self::assertStringNotContainsString('geht hier nicht', $this->row($html, 'lit/media-upload')); + } + + public function testReadonlyDisablesWriteToolSelectsButKeepsTheirOverride(): void + { + $html = $this->render(['@readonly:divi' => true, 'lit/divi-update' => 'indirect']); + + self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1" checked="checked"', $html); + $update = $this->row($html, 'lit/divi-update'); + self::assertStringContainsString('