Files
WP-LIT-MCP/tests/Unit/Admin/ToolsTabTest.php
T
janniclas.loosenandClaude Opus 5.5 776dbf8f4a 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>
2026-09-26 16:17:05 +02:00

234 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Admin\ToolsTab;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ToolsTabTest extends TestCase
{
/** @param list<array{string, bool, bool}> $abilities name, isDirect, readonly */
private function integration(string $id, array $abilities, bool $available = true): Integration
{
$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);
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
$mocks[] = $ability;
}
$integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn($id);
$integration->allows('isAvailable')->andReturn($available);
$integration->allows('abilities')->andReturn($mocks);
return $integration;
}
private function registry(): IntegrationRegistry
{
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)
);
}
/** @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('checked')->alias(
static fn ($a, $b = true, $echo = true): string => (string) $a === (string) $b ? ' checked="checked"' : ''
);
ob_start();
(new ToolsTab($registry ?? $this->registry(), new ExposurePolicy($option)))->render();
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"', $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);
}
}