feat: admin settings page UI/UX pass with WordPress-native patterns

- Header: h1 + logo in a wrapping flex row, hr.wp-header-end before the tabs,
  aria-current on the active tab; own CSS/JS enqueued only on this screen.
- Verbindung: ordered "So verbindest du Claude" steps, copy button (core
  clipboard.js + wp.a11y.speak), Site-Health-style status list, German details.
- Zugriff: roles as form-table fieldset, connections with site date format,
  empty-state row, confirm on revoke; ConnectionUsage records last use via
  WP_Application_Passwords::record_application_password_usage (once a day).
- Tools: plain-language legend, counter as inline notice, groups as core
  postboxes (postbox.js toggles, per-user closed state, summary when closed),
  fixed column widths with new "Art" column, submit above and below.
- Protokoll: site-timezone times with UTC tooltip, result icons, filters with
  labels in the list table nav, retention link to Limits.
- Limits: short labels with units and descriptions; snapshot count hidden but
  preserved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 16:50:58 +02:00
co-authored by Claude Opus 5.5
parent 5965e32acb
commit 843f45aeec
22 changed files with 1535 additions and 242 deletions
+7
View File
@@ -83,7 +83,14 @@ if (! class_exists('WP_List_Table')) {
{
}
/** Like the real class: the top table nav (with extra_tablenav()) comes before the rows. */
public function display(): void
{
$this->extra_tablenav('top');
}
/** @param string $which */
protected function extra_tablenav($which)
{
}
}
+74 -24
View File
@@ -171,21 +171,36 @@ final class AccessTabTest extends TestCase
(new AccessTab(new Access(), $connections))->handleRevoke();
}
public function testRenderShowsRolesAndConnections(): void
/** @param mixed $roles value of the roles option */
private function stubRender(mixed $roles): void
{
Functions\when('get_option')->justReturn(['editor']);
Functions\when('get_option')->alias(static fn (string $key, $default = false) => match ($key) {
'lit_mcp_allowed_roles' => $roles,
'date_format' => 'd.m.Y',
'time_format' => 'H:i',
default => $default,
});
Functions\when('translate_user_role')->returnArg();
Functions\when('checked')->alias(static fn ($a, $b = true, $echo = true): string => $a === $b ? ' checked="checked"' : '');
Functions\when('settings_fields')->justReturn(null);
Functions\when('submit_button')->alias(static function (): void {
echo '<button>Speichern</button>';
Functions\when('submit_button')->alias(static function ($text = null, $type = 'primary', $name = 'submit', $wrap = true, $other = ''): void {
$attrs = is_array($other) ? implode(' ', array_map(
static fn ($k, $v): string => sprintf('%s="%s"', $k, $v),
array_keys($other),
$other
)) : '';
echo '<button ' . $attrs . '>' . ($text ?? 'Speichern') . '</button>';
});
Functions\when('wp_nonce_field')->alias(static function (string $action): void {
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
});
Functions\when('wp_date')->justReturn('25.09.2026 10:00');
$connections = Mockery::mock(ConnectionRepository::class);
$connections->allows('all')->andReturn([[
Functions\when('wp_date')->alias(static fn (string $format, int $ts): string => $format . '@' . $ts);
}
/** @param array<string, mixed> $overrides */
private function connection(array $overrides = []): array
{
return $overrides + [
'user_id' => 3,
'user_login' => 'redaktion<b>',
'uuid' => 'uuid-1',
@@ -193,38 +208,73 @@ final class AccessTabTest extends TestCase
'app_id' => 'https://claude.ai/x',
'created' => 1790000000,
'last_used' => null,
]]);
];
}
private function renderTab(array $connections): string
{
$repository = Mockery::mock(ConnectionRepository::class);
$repository->allows('all')->andReturn($connections);
ob_start();
(new AccessTab(new Access(), $connections))->render();
$html = (string) ob_get_clean();
(new AccessTab(new Access(), $repository))->render();
return (string) ob_get_clean();
}
public function testRenderShowsRolesAndConnections(): void
{
$this->stubRender(['editor']);
$html = $this->renderTab([$this->connection()]);
self::assertStringContainsString('value="editor" checked="checked"', $html);
self::assertStringNotContainsString('value="administrator" checked', $html);
self::assertStringContainsString('Claude &lt;script&gt;', $html);
self::assertStringContainsString('<strong>Claude &lt;script&gt;</strong>', $html);
self::assertStringContainsString('redaktion&lt;b&gt;', $html);
self::assertStringNotContainsString('<script>', $html);
self::assertStringNotContainsString('<code>', $html);
self::assertStringContainsString('value="uuid-1"', $html);
self::assertStringContainsString('name="user_id" value="3"', $html);
self::assertStringContainsString('value="lit_mcp_revoke"', $html);
self::assertStringContainsString('nonce-lit_mcp_revoke_uuid-1', $html);
self::assertStringContainsString('confirm(', $html);
}
public function testRenderWithoutConnections(): void
public function testRolesAreAFormTableFieldsetWithLegendAndDescription(): void
{
Functions\when('get_option')->justReturn(['administrator']);
Functions\when('translate_user_role')->returnArg();
Functions\when('checked')->justReturn('');
Functions\when('settings_fields')->justReturn(null);
Functions\when('submit_button')->justReturn(null);
$connections = Mockery::mock(ConnectionRepository::class);
$connections->allows('all')->andReturn([]);
$this->stubRender(['editor']);
ob_start();
(new AccessTab(new Access(), $connections))->render();
$html = (string) ob_get_clean();
$html = $this->renderTab([]);
self::assertStringContainsString('Keine aktiven Verbindungen.', $html);
self::assertStringNotContainsString('<table', $html);
self::assertStringContainsString('<table class="form-table" role="presentation">', $html);
self::assertStringContainsString('<th scope="row">Rollen mit Zugriff</th>', $html);
self::assertStringContainsString('<fieldset><legend class="screen-reader-text"><span>Rollen mit Zugriff</span></legend>', $html);
self::assertStringContainsString('<p class="description">Nur Benutzer dieser Rollen können Claude verbinden.', $html);
}
public function testDatesUseTheSiteDateAndTimeFormat(): void
{
$this->stubRender(['editor']);
$html = $this->renderTab([
$this->connection(['uuid' => 'a', 'last_used' => null]),
$this->connection(['uuid' => 'b', 'last_used' => 1790050000]),
]);
self::assertStringContainsString('d.m.Y H:i@1790000000', $html);
self::assertStringContainsString('d.m.Y@1790050000', $html);
self::assertStringContainsString('<td class="column-last-used">–</td>', $html);
}
public function testRenderWithoutConnectionsShowsAnEmptyStateRowLinkingToTheConnectionTab(): void
{
$this->stubRender(['administrator']);
$html = $this->renderTab([]);
self::assertStringContainsString('<tr class="no-items"><td class="colspanchange" colspan="5">', $html);
self::assertStringContainsString('Noch keine Verbindung.', $html);
self::assertStringContainsString('href="https://kunde.de/wp-admin/options-general.php?page=lit-mcp&tab=verbindung"', $html);
}
}
+22 -2
View File
@@ -59,8 +59,28 @@ final class ConnectionStatusTest extends TestCase
$byLabel = array_column($checks, null, 'label');
self::assertFalse($byLabel['OAuth-Server']['ok']);
self::assertFalse($byLabel['HTTPS']['ok']);
self::assertNull($byLabel['Discovery (/.well-known/)']['ok']);
self::assertNull($byLabel['Endpunkt ohne Token']['ok']);
self::assertNull($byLabel['OAuth-Discovery']['ok']);
self::assertNull($byLabel['MCP-Endpunkt']['ok']);
}
public function testDiscoveryDetailIsGermanInsteadOfTheLibrarysEnglishLabel(): void
{
Functions\when('get_transient')->alias(static fn (string $key) => $key === 'lit_mcp_endpoint_check'
? false
: ['label' => 'MCP OAuth discovery documents', 'status' => 'good']);
Functions\when('home_url')->justReturn('https://kunde.test');
$good = array_column((new ConnectionStatus(static fn (): bool => true))->checks(false), null, 'label');
self::assertTrue($good['OAuth-Discovery']['ok']);
self::assertSame('Discovery-Dokumente erreichbar', $good['OAuth-Discovery']['message']);
Functions\when('get_transient')->alias(static fn (string $key) => $key === 'lit_mcp_endpoint_check'
? false
: ['label' => 'MCP OAuth discovery documents', 'status' => 'critical']);
$bad = array_column((new ConnectionStatus(static fn (): bool => true))->checks(false), null, 'label');
self::assertFalse($bad['OAuth-Discovery']['ok']);
self::assertStringContainsString('nicht erreichbar', $bad['OAuth-Discovery']['message']);
self::assertStringNotContainsString('documents', $bad['OAuth-Discovery']['message']);
}
public function testPermalinksPrettyReflectsThePermalinkStructureOption(): void
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Functions;
use LIT\MCP\Admin\ConnectionStatus;
use LIT\MCP\Admin\ConnectionTab;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ConnectionTabTest extends TestCase
{
/** @param list<array{label: string, ok: ?bool, message: string}> $checks */
private function render(array $checks): string
{
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
Functions\when('rest_url')->justReturn('https://kunde.de/wp-json/mcp/mcp-oauth-server');
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
$status = Mockery::mock(ConnectionStatus::class);
$status->allows('checks')->with(true)->andReturn($checks);
ob_start();
(new ConnectionTab($status))->render();
return (string) ob_get_clean();
}
public function testExplainsTheConnectionAsAnOrderedSequenceLinkingToTheAccessTab(): void
{
$html = $this->render([]);
self::assertStringContainsString('So verbindest du Claude', $html);
self::assertSame(3, substr_count($html, '<li>'));
self::assertStringContainsString('<ol', $html);
self::assertStringContainsString('„Add custom connector“', $html);
self::assertStringContainsString('href="https://kunde.de/wp-admin/options-general.php?page=lit-mcp&tab=zugriff"', $html);
}
public function testServerUrlIsReadonlyWithACopyButton(): void
{
$html = $this->render([]);
self::assertMatchesRegularExpression(
'#<input[^>]+id="lit-mcp-url"[^>]+readonly[^>]+value="https://kunde.de/wp-json/mcp/mcp-oauth-server"#',
$html
);
self::assertStringContainsString('<label for="lit-mcp-url"', $html);
self::assertStringContainsString(
'<button type="button" class="button lit-mcp-copy" data-clipboard-target="#lit-mcp-url">URL kopieren</button>',
$html
);
self::assertStringContainsString('<span class="success hidden" aria-hidden="true">Kopiert!</span>', $html);
}
public function testStatusIsASiteHealthStyleListWithIconsPerState(): void
{
$html = $this->render([
['label' => 'HTTPS', 'ok' => true, 'message' => 'aktiv'],
['label' => 'Discovery', 'ok' => null, 'message' => 'noch nicht geprüft'],
['label' => 'OAuth <b>', 'ok' => false, 'message' => 'deaktiviert'],
]);
self::assertStringNotContainsString('<table', $html);
self::assertStringContainsString('<ul class="lit-mcp-status">', $html);
self::assertMatchesRegularExpression('#dashicons-yes-alt[^<]*"[^>]*></span>.*?HTTPS#s', $html);
self::assertMatchesRegularExpression('#dashicons-warning[^<]*"[^>]*></span>.*?Discovery#s', $html);
self::assertMatchesRegularExpression('#dashicons-dismiss[^<]*"[^>]*></span>.*?OAuth &lt;b&gt;#s', $html);
self::assertStringContainsString('<span class="screen-reader-text">In Ordnung:</span>', $html);
self::assertStringContainsString('<span class="screen-reader-text">Problem:</span>', $html);
self::assertStringContainsString('href="https://kunde.de/wp-admin/site-health.php"', $html);
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Functions;
use LIT\MCP\Admin\ConnectionUsage;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Error;
use WP_REST_Request;
final class ConnectionUsageTest extends TestCase
{
/** @var list<array{int, string}> */
private array $recorded = [];
private function usage(?array $claims): ConnectionUsage
{
return new ConnectionUsage(
static fn (string $token): ?array => $token === 'valid-token' ? $claims : null,
function (int $userId, string $uuid): void {
$this->recorded[] = [$userId, $uuid];
}
);
}
private function request(string $route = '/mcp/mcp-oauth-server', string $auth = 'Bearer valid-token'): WP_REST_Request
{
return new WP_REST_Request($route, ['authorization' => $auth]);
}
public function testRegistersAfterTheRouteCallback(): void
{
$usage = $this->usage([]);
Functions\expect('add_filter')->once()->with('rest_request_after_callbacks', [$usage, 'record'], 10, 3);
$usage->register();
}
public function testRecordsUsageOfTheTokensApplicationPasswordForTheAuthenticatedUser(): void
{
Functions\when('get_current_user_id')->justReturn(7);
$response = ['jsonrpc' => '2.0'];
$result = $this->usage(['sub' => 7, 'app_pass_id' => 'uuid-1'])->record($response, [], $this->request());
self::assertSame($response, $result);
self::assertSame([[7, 'uuid-1']], $this->recorded);
}
public function testIgnoresOtherRoutesFailedRequestsAndForeignOrInvalidTokens(): void
{
Functions\when('get_current_user_id')->justReturn(7);
$claims = ['sub' => 7, 'app_pass_id' => 'uuid-1'];
$this->usage($claims)->record([], [], $this->request('/wp/v2/posts'));
$this->usage($claims)->record(new WP_Error('unauthorized', 'x'), [], $this->request());
$this->usage($claims)->record([], [], $this->request('/mcp/mcp-oauth-server', 'Basic abc'));
$this->usage($claims)->record([], [], $this->request('/mcp/mcp-oauth-server', 'Bearer forged'));
$this->usage(['sub' => 8, 'app_pass_id' => 'uuid-1'])->record([], [], $this->request());
$this->usage(['sub' => 7])->record([], [], $this->request());
self::assertSame([], $this->recorded);
}
public function testNeverBreaksTheResponseWhenDecodingOrRecordingFails(): void
{
Functions\when('get_current_user_id')->justReturn(7);
$usage = new ConnectionUsage(
static fn (): ?array => throw new \RuntimeException('bad secret'),
static function (): void {
}
);
self::assertSame(['ok'], $usage->record(['ok'], [], $this->request()));
}
}
+17 -6
View File
@@ -24,24 +24,35 @@ final class LimitsTabTest extends TestCase
(new LimitsTab())->registerSettings();
}
public function testRendersThreeNumberFieldsWithCurrentValues(): void
public function testRendersVisibleNumberFieldsWithShortLabelsUnitsAndDescriptions(): void
{
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
Functions\when('settings_fields')->justReturn(null);
Functions\when('submit_button')->justReturn(null);
Functions\when('get_option')->justReturn(['upload_mb' => 8]);
Functions\when('get_option')->justReturn(['upload_mb' => 8, 'snapshot_count' => 42]);
Functions\when('wp_max_upload_size')->justReturn(64 * 1024 * 1024);
ob_start();
(new LimitsTab())->render();
$html = (string) ob_get_clean();
self::assertSame(3, substr_count($html, 'type="number"'));
self::assertSame(2, substr_count($html, 'type="number"'));
self::assertStringContainsString('<label for="lit-mcp-upload_mb">Maximale Dateigröße</label>', $html);
self::assertStringContainsString('<label for="lit-mcp-log_retention_days">Protokoll aufbewahren</label>', $html);
self::assertStringContainsString('class="small-text"', $html);
self::assertStringContainsString('<span class="lit-mcp-unit">MB</span>', $html);
self::assertStringContainsString('<span class="lit-mcp-unit">Tage</span>', $html);
self::assertStringContainsString('aria-describedby="lit-mcp-upload_mb-description"', $html);
self::assertStringContainsString('<p class="description" id="lit-mcp-upload_mb-description">Höchstens 32 MB; für Base64-Übertragungen höchstens 10 MB.</p>', $html);
// The snapshot setting is hidden until its feature exists, but its saved value survives a save.
self::assertStringNotContainsString('Designsystem', $html);
self::assertStringNotContainsString('Etappe', $html);
self::assertStringContainsString('<input type="hidden" name="lit_mcp_limits[snapshot_count]" value="42">', $html);
self::assertStringContainsString('name="lit_mcp_limits[upload_mb]" value="8"', $html);
self::assertStringContainsString('name="lit_mcp_limits[log_retention_days]" value="90"', $html);
self::assertStringContainsString('min="1" max="32"', $html);
self::assertStringContainsString('höchstens 32', $html);
self::assertStringContainsString('Höchstens 32', $html);
self::assertStringContainsString('10 MB', $html);
self::assertStringContainsString('min="1" max="3650"', $html);
}
@@ -59,7 +70,7 @@ final class LimitsTabTest extends TestCase
(new LimitsTab())->render();
$html = (string) ob_get_clean();
self::assertStringContainsString('class="description"', $html);
self::assertStringContainsString('notice-warning inline', $html);
self::assertMatchesRegularExpression('/20 MB.*2 MB/s', $html);
}
@@ -76,6 +87,6 @@ final class LimitsTabTest extends TestCase
(new LimitsTab())->render();
$html = (string) ob_get_clean();
self::assertStringNotContainsString('class="description"', $html);
self::assertStringNotContainsString('notice-warning', $html);
}
}
+63 -1
View File
@@ -191,10 +191,72 @@ final class LogListTableTest extends TestCase
$table->single_row(['result' => 'ok']);
$ok = (string) ob_get_clean();
self::assertStringContainsString('<tr style=', $partial);
self::assertStringContainsString('<tr class="lit-mcp-partial">', $partial);
self::assertSame('<tr>', substr($ok, 0, 4));
}
public function testTimeColumnShowsSiteTimeInWordPressFormatWithUtcAsTitle(): void
{
Functions\when('get_option')->alias(static fn (string $key) => $key === 'date_format' ? 'd.m.Y' : 'H:i');
Functions\when('wp_date')->alias(static fn (string $format, int $ts): string => $format . '@' . $ts);
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
self::assertSame('Zeit', $table->get_columns()['created_at']);
$html = $this->column($table, ['created_at' => '2026-09-26 10:39:13'], 'created_at');
self::assertStringContainsString('d.m.Y H:i@' . gmmktime(10, 39, 13, 9, 26, 2026), $html);
self::assertStringContainsString('title="UTC: 2026-09-26 10:39:13"', $html);
self::assertSame('–', $this->column($table, ['created_at' => 'kaputt'], 'created_at'));
}
/** @dataProvider results */
public function testResultColumnShowsIconAndWord(string $result, string $icon, string $word): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0]);
$html = $this->column($table, ['result' => $result], 'result');
self::assertStringContainsString('dashicons-' . $icon, $html);
self::assertStringContainsString($word, $html);
}
/** @return list<list<string>> */
public static function results(): array
{
return [
['ok', 'yes-alt', 'erfolgreich'],
['partial', 'warning', 'teilweise'],
['error<b>', 'dismiss', 'error&lt;b&gt;'],
];
}
public function testToolColumnIsMutedTextAndTopNavRendersTheGivenFilters(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0], static function (): void {
echo '<select name="ability"></select>';
});
self::assertSame(
'<span class="lit-mcp-tool-name">lit/x&lt;</span>',
$this->column($table, ['ability' => 'lit/x<'], 'ability')
);
ob_start();
$table->display();
self::assertStringContainsString('<div class="alignleft actions"><select name="ability"></select></div>', (string) ob_get_clean());
}
/** @param array<string, mixed> $item */
private function column(LogListTable $table, array $item, string $column): string
{
$method = 'column_' . $column;
if (method_exists($table, $method)) {
return (string) (new \ReflectionMethod($table, $method))->invoke($table, $item);
}
return $this->columnDefault($table, $item, $column);
}
/** @param array<string, mixed> $item */
private function columnDefault(LogListTable $table, array $item, string $column): string
{
+15 -1
View File
@@ -130,10 +130,11 @@ final class LogTabTest extends TestCase
echo '<input type="hidden" name="_wpnonce" value="nonce-' . $action . '">';
});
Functions\when('wp_dropdown_users')->alias(static function (array $args): void {
echo '<select name="' . $args['name'] . '"><option>Alle</option></select>';
echo '<select name="' . $args['name'] . '" id="' . $args['id'] . '"><option>Alle</option></select>';
});
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
Functions\when('get_option')->justReturn([]);
Functions\when('add_query_arg')->alias(static fn (array $args, string $url): string => $url . '?' . http_build_query($args));
$log = Mockery::mock(ChangeLog::class);
$log->allows('abilities')->andReturn(['lit/content-update', 'lit/x"><script>']);
@@ -151,5 +152,18 @@ final class LogTabTest extends TestCase
self::assertStringContainsString('lit_mcp_clear_log', $html);
self::assertStringContainsString('confirm(', $html);
self::assertStringContainsString('nonce-lit_mcp_clear_log', $html);
// One intro line; the retention days link to the Limits tab.
self::assertMatchesRegularExpression('#<p class="description">[^<]*<a href="[^"]*tab=limits">90 Tagen</a>#', $html);
// Every filter control has a (screen-reader) label.
foreach (['ability', 'user_id', 'result', 'date_from', 'date_to'] as $name) {
self::assertMatchesRegularExpression(
'#<label class="screen-reader-text" for="lit-mcp-filter-' . str_replace('_', '-', $name) . '">#',
$html,
'label for ' . $name
);
}
self::assertStringContainsString('<option value="ok">erfolgreich</option>', $html);
self::assertStringContainsString('<option value="partial">teilweise</option>', $html);
}
}
+49 -2
View File
@@ -53,7 +53,8 @@ final class SettingsPageTest extends TestCase
self::assertStringContainsString('Inhalt zugriff', $html);
self::assertStringNotContainsString('Inhalt verbindung', $html);
self::assertMatchesRegularExpression('/tab=zugriff" class="nav-tab nav-tab-active"/', $html);
self::assertMatchesRegularExpression('/tab=zugriff" class="nav-tab nav-tab-active" aria-current="page"/', $html);
self::assertMatchesRegularExpression('/tab=verbindung" class="nav-tab">/', $html);
self::assertSame(['verbindung', 'zugriff'], $page->tabSlugs());
}
@@ -70,8 +71,53 @@ final class SettingsPageTest extends TestCase
$withoutLogo = (string) ob_get_clean();
self::assertStringContainsString('<img src="https://example.test/logo.svg" alt="Loosen-IT Logo"', $withLogo);
self::assertStringContainsString('right:0', $withLogo);
self::assertStringNotContainsString('lit-mcp-logo', $withoutLogo);
// Logo sits in the header next to the h1 (flex, no absolute positioning over the title).
self::assertLessThan(strpos($withLogo, 'lit-mcp-logo'), strpos($withLogo, '<h1'));
self::assertStringNotContainsString('position:absolute', $withLogo);
}
public function testHeaderEndMarkerSitsBetweenTitleAndTabsWithNoticesAfterIt(): void
{
Functions\when('current_user_can')->justReturn(true);
$_GET['lit_mcp_notice'] = 'revoked';
ob_start();
(new SettingsPage([$this->tab('verbindung', 'Verbindung')]))->render();
$html = (string) ob_get_clean();
$h1 = (int) strpos($html, '<h1');
$end = strpos($html, '<hr class="wp-header-end">');
self::assertNotFalse($end);
self::assertLessThan($end, $h1);
self::assertLessThan(strpos($html, 'notice-success'), $end);
self::assertLessThan(strpos($html, 'nav-tab-wrapper'), (int) strpos($html, 'notice-success'));
self::assertStringContainsString('<nav class="nav-tab-wrapper wp-clearfix" aria-label="', $html);
}
public function testEnqueuesStylesAndCopyScriptOnlyOnItsOwnScreen(): void
{
$page = new SettingsPage([$this->tab('verbindung', 'Verbindung')], '', 'https://example.test/assets', '1.2.3');
Functions\expect('wp_enqueue_style')->once()
->with('lit-mcp-admin', 'https://example.test/assets/css/admin.css', [], '1.2.3');
Functions\expect('wp_enqueue_script')->once()
->with('lit-mcp-admin', 'https://example.test/assets/js/admin.js', ['clipboard', 'wp-a11y'], '1.2.3', true);
Functions\when('wp_localize_script')->justReturn(true);
$page->enqueueAssets('settings_page_lit-mcp');
$page->enqueueAssets('options-general.php');
}
public function testToolsTabAlsoLoadsWordPressPostboxScriptForCollapsibleGroups(): void
{
$_GET['tab'] = 'tools';
$page = new SettingsPage([$this->tab('verbindung', 'V'), $this->tab('tools', 'T')], '', 'https://example.test/assets/', '1');
Functions\when('wp_enqueue_style')->justReturn(null);
Functions\expect('wp_enqueue_script')->once()
->with('lit-mcp-admin', 'https://example.test/assets/js/admin.js', ['clipboard', 'wp-a11y', 'postbox'], '1', true);
Functions\when('wp_localize_script')->justReturn(true);
$page->enqueueAssets('settings_page_lit-mcp');
}
public function testUnknownTabFallsBackToTheFirstAndNoticesAreShown(): void
@@ -108,6 +154,7 @@ final class SettingsPageTest extends TestCase
$page = new SettingsPage([$tab]);
Functions\expect('add_action')->once()->with('admin_menu', [$page, 'addMenu']);
Functions\expect('add_action')->once()->with('admin_init', [$page, 'registerSettings']);
Functions\expect('add_action')->once()->with('admin_enqueue_scripts', [$page, 'enqueueAssets']);
$page->register();
}
+133 -21
View File
@@ -15,16 +15,17 @@ use Mockery;
final class ToolsTabTest extends TestCase
{
/** @param list<array{string, bool, bool}> $abilities name, isDirect, readonly */
/** @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 [$name, $direct, $readonly]) {
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]);
$ability->allows('annotations')->andReturn(['readonly' => $readonly, 'destructive' => $spec[3] ?? false]);
$mocks[] = $ability;
}
$integration = Mockery::mock(Integration::class);
@@ -45,13 +46,24 @@ final class ToolsTabTest extends TestCase
);
}
/** @param array<string, string|bool> $option */
private function render(array $option, ?IntegrationRegistry $registry = null): string
/**
* @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')->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"' : ''
);
@@ -76,21 +88,22 @@ final class ToolsTabTest extends TestCase
/** 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>');
$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 <details> block of one group. */
/** 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, '<summary>' . $label);
$at = strpos($html, '<h2 class="hndle">' . $label . ' ');
self::assertNotFalse($at, 'No group ' . $label);
$start = (int) strrpos(substr($html, 0, $at), '<details');
$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, (int) strpos($html, '</details>', $at) - $start);
return substr($html, $start, ($end === false ? strlen($html) : $end) - $start);
}
public function testRegistersTheExposureOptionWithAGroupAwareSanitizer(): void
@@ -125,10 +138,10 @@ final class ToolsTabTest extends TestCase
{
$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::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'));
@@ -139,7 +152,10 @@ final class ToolsTabTest extends TestCase
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);
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);
@@ -182,14 +198,14 @@ final class ToolsTabTest extends TestCase
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('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);
self::assertStringNotContainsString('Nur lesen', $outline);
}
public function testCountsDirectToolsUnderTheSavedSettings(): void
@@ -199,7 +215,8 @@ final class ToolsTabTest extends TestCase
// 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'));
self::assertLessThan(strpos($html, 'class="postbox'), strpos($html, 'Direkt sichtbar'));
self::assertStringContainsString('<div class="notice notice-info inline"><p><strong>Direkt sichtbar: 3 Tools', $html);
}
public function testWarnsWhenMoreThan25ToolsAreDirect(): void
@@ -215,7 +232,9 @@ final class ToolsTabTest extends TestCase
self::assertStringContainsString('Direkt sichtbar: 26 Tools', $warned);
self::assertStringContainsString('Kontext', $warned);
self::assertStringContainsString('<summary>WordPress (26 Tools)', $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::assertStringContainsString('Direkt sichtbar: 25 Tools', $notWarned);
self::assertStringNotContainsString('Kontext', $notWarned);
}
@@ -226,8 +245,101 @@ final class ToolsTabTest extends TestCase
$html = $this->render([], $registry);
self::assertStringContainsString('<summary>shop (1 Tool)', $html);
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],
['lit/media-delete', false, false, true],
]));
$html = $this->render([], $registry);
self::assertStringContainsString('<th scope="col" class="column-kind">Art</th>', $html);
self::assertStringContainsString('liest', $this->row($html, 'lit/media-preview'));
self::assertStringContainsString('schreibt', $this->row($html, 'lit/media-upload'));
self::assertStringContainsString('löscht', $this->row($html, 'lit/media-delete'));
self::assertStringNotContainsString('schreibt', $this->row($html, 'lit/media-delete'));
}
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 testIntroUsesPlainLanguageAndMentionsDiviOnlyWhenDiviIsAvailable(): void
{
$withDivi = $this->render([]);
$withoutDivi = $this->render([], new IntegrationRegistry($this->integration('core', [['lit/guide', true, true]])));
self::assertStringContainsString('Claude sieht das Tool sofort.', $withDivi);
self::assertStringContainsString('Claude findet es bei Bedarf über die Tool-Suche.', $withDivi);
self::assertStringNotContainsString('Meta-Tools', $withDivi);
self::assertStringNotContainsString('discover', $withDivi);
self::assertStringContainsString('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">', $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">', $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">', $default);
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group">', $saved);
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group closed">', $saved);
}
}