feat: group tools by integration with inherited group mode and "nur lesen" switch

Tools tab shows one block per integration with a group mode (inherited by its
tools) and a read-only switch that disables all non-readonly abilities of the
group. ExposurePolicy resolves tool override > group override > tool default;
lit/guide keeps its default, direct-only tools fall back to direct under a
group "indirekt". Group entries live in lit_mcp_exposure under "@group:<id>"
and "@readonly:<id>"; the old flat format stays valid. The ability->group map
comes lazily from IntegrationRegistry (Divi is not loaded at plugins_loaded).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 16:17:05 +02:00
co-authored by Claude Opus 5.5
parent ede0221b50
commit 776dbf8f4a
10 changed files with 847 additions and 92 deletions
+191 -25
View File
@@ -15,53 +15,219 @@ use Mockery;
final class ToolsTabTest extends TestCase
{
private function registry(): IntegrationRegistry
/** @param list<array{string, bool, bool}> $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<string, string|bool> $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('<select', $html), 1);
self::assertCount(3, $selects);
[$guide, $preview, $menu] = $selects;
self::assertStringContainsString('lit_mcp_exposure[lit/guide]', $guide);
return (string) ob_get_clean();
}
private function select(string $html, string $key): string
{
$start = strpos($html, '<select name="lit_mcp_exposure[' . $key . ']"');
self::assertNotFalse($start, 'No select for ' . $key);
return substr($html, $start, (int) strpos($html, '</select>', $start) - $start);
}
/** The table row of one tool (from its <tr> to </tr>). */
private function row(string $html, string $name): string
{
$at = strpos($html, '<code>' . $name . '</code>');
self::assertNotFalse($at, 'No row for ' . $name);
$start = (int) strrpos(substr($html, 0, $at), '<tr');
return substr($html, $start, (int) strpos($html, '</tr>', $at) - $start);
}
/** The <details> block of one group. */
private function group(string $html, string $label): string
{
$at = strpos($html, '<summary>' . $label);
self::assertNotFalse($at, 'No group ' . $label);
$start = (int) strrpos(substr($html, 0, $at), '<details');
return substr($html, $start, (int) strpos($html, '</details>', $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, '<details open'));
self::assertStringContainsString('<summary>Grundlagen (2 Tools)', $html);
self::assertStringContainsString('<summary>Medien (2 Tools)', $html);
self::assertStringContainsString('<summary>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('<option value="" selected="selected">Standard der Tools</option>', $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(
'<option value="" selected="selected">wie Gruppe (indirekt)</option>',
$this->select($html, 'lit/divi-outline')
);
$update = $this->select($html, 'lit/divi-update');
self::assertStringContainsString('<option value="">wie Gruppe (indirekt)</option>', $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('<select name="lit_mcp_exposure[lit/divi-update]" disabled', $update);
self::assertStringContainsString('durch ‚nur lesen‘ aus', $update);
self::assertStringContainsString(
'<input type="hidden" name="lit_mcp_exposure[lit/divi-update]" value="indirect">',
$update
);
$outline = $this->row($html, 'lit/divi-outline');
self::assertStringNotContainsString('disabled>', $outline);
self::assertStringNotContainsString('nur lesen', $outline);
}
public function testCountsDirectToolsUnderTheSavedSettings(): void
{
$html = $this->render(['@group:divi' => 'disabled']);
// guide, site-status, media-preview (divi disabled, media-upload indirect by default).
self::assertStringContainsString('Direkt sichtbar: 3 Tools', $html);
self::assertStringNotContainsString('Kontext', $html);
self::assertLessThan(strpos($html, '<details'), strpos($html, 'Direkt sichtbar'));
}
public function testWarnsWhenMoreThan25ToolsAreDirect(): void
{
$many = [];
for ($i = 1; $i <= 26; $i++) {
$many[] = ['lit/tool-' . $i, true, true];
}
$registry = new IntegrationRegistry($this->integration('wordpress', $many));
$warned = $this->render([], $registry);
$notWarned = $this->render(['lit/tool-1' => 'indirect'], $registry);
self::assertStringContainsString('Direkt sichtbar: 26 Tools', $warned);
self::assertStringContainsString('Kontext', $warned);
self::assertStringContainsString('<summary>WordPress (26 Tools)', $warned);
self::assertStringContainsString('Direkt sichtbar: 25 Tools', $notWarned);
self::assertStringNotContainsString('Kontext', $notWarned);
}
public function testUnknownGroupsUseTheirIdAndEverythingIsEscaped(): void
{
$registry = new IntegrationRegistry($this->integration('shop', [['lit/shop-<b>', true, true]]));
$html = $this->render([], $registry);
self::assertStringContainsString('<summary>shop (1 Tool)', $html);
self::assertStringNotContainsString('<b>', $html);
self::assertStringContainsString('lit/shop-&lt;b&gt;', $html);
}
}