Files
WP-LIT-MCP/tests/Unit/Admin/ToolsTabTest.php
T
Jan-Niclas LoosenandClaude Fable 5.1 1b73f0652a fix: Admin-UX aus dem Review – Live-Gruppenfeedback, Protokollfilter, Divi-Hinweis, Rollen-Rückfrage
- Tools: „Ganze Gruppe“ und „Nur lesen“ wirken sofort auf die Zeilen
  (Option „wie Gruppe (…)“, gesperrte Selects mit Hinweis), der Server
  bleibt die Wahrheit; Markup trägt die nötigen data-Attribute.
- Protokoll: Filter in eigenem Block über der Tabelle (Core blendet
  .tablenav .actions auf Telefonen aus); „Protokoll leeren“ nur bei
  Einträgen, rechtsbündig, mit Gesamtzahl über ChangeLog::count().
- Divi: Beschreibung unter „Prüfung starten“, Button primär solange
  ungetestet ohne frischen Bericht.
- Zugriff: Rückfrage vor dem Speichern ohne Rolle oder ohne eigene Rolle.
- Verbindung: HTTPS-Hinweis nicht doppelt neben der Statusliste.
- Mobil: breitere Einstellungs-Spalte im Tools-Reiter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 16:39:44 +02:00

408 lines
21 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{0: string, 1: bool, 2: bool, 3?: bool}> $abilities name, isDirect, readonly, destructive */
private function integration(string $id, array $abilities, bool $available = true): Integration
{
$mocks = [];
foreach ($abilities as $spec) {
[$name, $direct, $readonly] = $spec;
$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, 'destructive' => $spec[3] ?? false]);
$mocks[] = $ability;
}
$integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn($id);
$integration->allows('label')->andReturn(
['core' => 'Grundlagen', 'media' => 'Medien', 'divi' => 'Divi', 'wordpress' => 'WordPress'][$id] ?? $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
* @param mixed $closedBoxes the user's saved closed postboxes (false = never saved)
*/
private function render(array $option, ?IntegrationRegistry $registry = null, mixed $closedBoxes = false): string
{
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
Functions\when('settings_fields')->justReturn(null);
Functions\when('submit_button')->alias(static function ($text = null, $type = 'primary', $name = 'submit'): void {
echo '<button name="' . $name . '">Speichern</button>';
});
Functions\when('wp_nonce_field')->alias(static function (string $action, string $name): string {
return '<input type="hidden" id="' . $name . '" name="' . $name . '" value="nonce-' . $action . '">';
});
Functions\when('get_user_option')->alias(
static fn (string $key) => $key === 'closedpostboxes_settings_page_lit-mcp' ? $closedBoxes : false
);
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, '<span class="lit-mcp-tool-name">' . $name . '</span>');
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 postbox of one group (from its opening div to the next group or the end). */
private function group(string $html, string $label): string
{
$at = strpos($html, '<h2 class="hndle">' . $label . ' ');
self::assertNotFalse($at, 'No group ' . $label);
$start = (int) strrpos(substr($html, 0, $at), '<div id="lit-mcp-group-');
$end = strpos($html, '<div id="lit-mcp-group-', $at);
return substr($html, $start, ($end === false ? strlen($html) : $end) - $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, 'class="postbox lit-mcp-group'));
self::assertStringContainsString('<h2 class="hndle">Grundlagen <span class="count">(2 Tools)</span>', $html);
self::assertStringContainsString('<h2 class="hndle">Medien <span class="count">(2 Tools)</span>', $html);
self::assertStringContainsString('<h2 class="hndle">Divi <span class="count">(2 Tools)</span>', $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('<label for="lit-mcp-group-mode-divi">Ganze Gruppe</label>', $html);
self::assertStringContainsString('id="lit-mcp-group-mode-divi"', $groupSelect);
self::assertStringContainsString('Nur lesen', $this->group($html, 'Divi'));
self::assertStringContainsString('<option value="disabled">aus</option>', $groupSelect);
$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::assertMatchesRegularExpression('#<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);
}
/** The admin script mirrors a group's mode and "nur lesen" switch onto its rows before saving. */
public function testMarkupCarriesWhatTheScriptNeedsForLiveGroupFeedback(): void
{
$html = $this->render([]);
self::assertStringContainsString('<div class="lit-mcp-groups" data-like-group="wie Gruppe (%s)">', $html);
self::assertStringContainsString('class="lit-mcp-group-mode"', $this->select($html, '@group:divi'));
self::assertStringContainsString('class="lit-mcp-group-readonly"', $this->group($html, 'Divi'));
$update = $this->select($html, 'lit/divi-update');
self::assertStringContainsString('class="lit-mcp-tool-mode"', $update);
self::assertStringContainsString('data-default-label="Standard (direkt)"', $update);
self::assertStringContainsString('data-writes="1"', $update);
self::assertStringNotContainsString('data-writes', $this->select($html, 'lit/divi-outline'));
self::assertStringContainsString('data-required="1"', $this->select($html, 'lit/guide'));
self::assertStringContainsString('data-direct-only="1"', $this->select($html, 'lit/media-preview'));
self::assertStringNotContainsString('data-direct-only', $update);
// Every write row carries the "nur lesen" note, hidden until the switch is on.
self::assertStringContainsString(
'<p class="description lit-mcp-readonly-note" hidden>Durch „Nur lesen“ aus</p>',
$this->row($html, 'lit/divi-update')
);
self::assertStringNotContainsString('lit-mcp-readonly-note', $this->row($html, 'lit/divi-outline'));
$locked = $this->render(['@readonly:divi' => true]);
self::assertStringContainsString(
'<p class="description lit-mcp-readonly-note">Durch „Nur lesen“ aus</p>',
$this->row($locked, 'lit/divi-update')
);
}
public function testCountsDirectToolsUnderTheSavedSettings(): void
{
$html = $this->render(['@group:divi' => 'disabled']);
// guide, site-status, media-preview (divi disabled, media-upload indirect by default).
// Few direct tools: no counter at all (only a warning above the threshold is shown).
self::assertStringNotContainsString('Direkt sichtbar', $html);
self::assertStringNotContainsString('Kontext', $html);
}
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('<div class="notice notice-warning inline">', $warned);
self::assertStringNotContainsString('notice-warning', $notWarned);
self::assertStringContainsString('<h2 class="hndle">WordPress <span class="count">(26 Tools)</span>', $warned);
self::assertStringNotContainsString('Direkt sichtbar', $notWarned);
self::assertStringNotContainsString('Kontext', $notWarned);
self::assertLessThan(strpos($warned, 'class="postbox'), strpos($warned, 'Direkt sichtbar'));
}
public function testUnknownGroupsUseTheirIdAndEverythingIsEscaped(): void
{
$registry = new IntegrationRegistry($this->integration('shop', [['lit/shop-<b>', true, true]]));
$html = $this->render([], $registry);
self::assertStringContainsString('<h2 class="hndle">shop <span class="count">(1 Tool)</span>', $html);
self::assertStringNotContainsString('<b>', $html);
self::assertStringContainsString('lit/shop-&lt;b&gt;', $html);
}
public function testArtColumnSaysWhetherAToolReadsWritesOrDeletes(): void
{
$registry = new IntegrationRegistry($this->integration('media', [
['lit/media-preview', true, true],
['lit/media-upload', false, false],
]));
$html = $this->render([], $registry);
self::assertStringContainsString('<th scope="col" class="column-kind">Art</th>', $html);
self::assertStringContainsString('dashicons-visibility', $this->row($html, 'lit/media-preview'));
self::assertStringContainsString('>liest<', $this->row($html, 'lit/media-preview'));
self::assertStringContainsString('dashicons-edit', $this->row($html, 'lit/media-upload'));
self::assertStringContainsString('>schreibt<', $this->row($html, 'lit/media-upload'));
}
/**
* "destructive" is a broad hint (deleting, replacing, swapping) – only explicit deletes say "löscht".
*
* @dataProvider destructiveAbilities
*/
public function testArtColumnForEveryDestructiveAbility(string $name, string $icon, string $word): void
{
$registry = new IntegrationRegistry($this->integration('wordpress', [[$name, false, false, true]]));
$row = $this->row($this->render([], $registry), $name);
self::assertStringContainsString('dashicons-' . $icon, $row);
self::assertStringContainsString('>' . $word . '<', $row);
}
/** @return list<list<string>> */
public static function destructiveAbilities(): array
{
return [
['lit/media-delete', 'trash', 'löscht'],
['lit/divi-delete', 'trash', 'löscht'],
['lit/post-trash', 'trash', 'Papierkorb'],
['lit/post-replace-with-copy', 'update', 'überschreibt'],
['lit/revision-restore', 'update', 'überschreibt'],
['lit/menu-set-items', 'update', 'überschreibt'],
['lit/menu-assign-location', 'update', 'überschreibt'],
['lit/divi-replace-text', 'update', 'überschreibt'],
];
}
public function testToolCellShowsLabelAndTheNameAsMutedTextWithALabelledSelect(): void
{
$html = $this->render([]);
$row = $this->row($html, 'lit/guide');
self::assertStringContainsString('<strong>Label lit/guide</strong>', $row);
self::assertStringNotContainsString('<code>', $row);
self::assertMatchesRegularExpression('#<label class="screen-reader-text" for="([a-z0-9-]+)">[^<]*Label lit/guide</label>#', $row);
preg_match('#for="([a-z0-9-]+)"#', $row, $m);
self::assertStringContainsString('id="' . $m[1] . '"', $this->select($html, 'lit/guide'));
}
public function testIntroUsesPlainLanguageWithoutImplementationNotes(): void
{
$withDivi = $this->render([]);
$withoutDivi = $this->render([], new IntegrationRegistry($this->integration('core', [['lit/guide', true, true]])));
self::assertStringContainsString('Der Assistent sieht das Tool sofort.', $withDivi);
self::assertStringContainsString('Der Assistent findet es bei Bedarf über die Tool-Suche.', $withDivi);
self::assertStringNotContainsString('Meta-Tools', $withDivi);
self::assertStringNotContainsString('discover', $withDivi);
self::assertStringNotContainsString('Divi-Code-Modul', $withDivi);
self::assertStringNotContainsString('Divi-Code-Modul', $withoutDivi);
}
public function testHasASubmitButtonAboveAndBelowTheGroups(): void
{
$html = $this->render([]);
self::assertSame(2, substr_count($html, 'Speichern</button>'));
self::assertLessThan(strpos($html, 'class="postbox'), strpos($html, 'name="submit-top"'));
self::assertGreaterThan(strrpos($html, 'class="postbox'), strrpos($html, 'name="submit"'));
}
public function testGroupsAreWordPressPostboxesWithAnAccessibleToggle(): void
{
$html = $this->render([]);
$divi = $this->group($html, 'Divi');
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group" data-group="divi">', $divi);
self::assertStringContainsString('<div class="postbox-header">', $divi);
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="true">', $divi);
self::assertStringContainsString('<span class="screen-reader-text">Gruppe ein-/ausblenden: Divi</span>', $divi);
self::assertStringContainsString('<span class="toggle-indicator" aria-hidden="true"></span>', $divi);
self::assertStringContainsString('<div class="inside">', $divi);
// The group controls live in the header but outside the clickable heading.
$heading = substr($divi, (int) strpos($divi, '<h2'), (int) strpos($divi, '</h2>') - (int) strpos($divi, '<h2'));
self::assertStringNotContainsString('<select', $heading);
self::assertStringNotContainsString('<input', $heading);
self::assertLessThan(strpos($divi, 'class="inside"'), strpos($divi, 'lit-mcp-group-mode-divi'));
// Nonce for WordPress' own "closed-postboxes" state saving.
self::assertStringContainsString('id="closedpostboxesnonce" name="closedpostboxesnonce" value="nonce-closedpostboxes"', $html);
}
public function testHeaderSummaryCountsEffectiveModes(): void
{
$html = $this->render(['@readonly:divi' => true]);
self::assertStringContainsString('<span class="lit-mcp-group-summary">2 direkt · 0 indirekt · 0 aus</span>', $this->group($html, 'Grundlagen'));
self::assertStringContainsString('<span class="lit-mcp-group-summary">1 direkt · 1 indirekt · 0 aus</span>', $this->group($html, 'Medien'));
self::assertStringContainsString('<span class="lit-mcp-group-summary">nur lesen · 1 direkt · 0 indirekt · 1 aus</span>', $this->group($html, 'Divi'));
}
public function testAGroupWithAllToolsOffStartsClosedUnlessTheUserSavedOtherwise(): void
{
$default = $this->render(['@group:divi' => 'disabled']);
$saved = $this->render(['@group:divi' => 'disabled'], null, ['lit-mcp-group-media']);
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group closed" data-group="divi">', $default);
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="false">', $this->group($default, 'Divi'));
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group" data-group="media">', $default);
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group" data-group="divi">', $saved);
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group closed" data-group="media">', $saved);
}
}