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>
This commit is contained in:
Jan-Niclas Loosen
2026-10-08 16:39:44 +02:00
co-authored by Claude Fable 5.1
parent cc72f9b946
commit 1b73f0652a
17 changed files with 462 additions and 150 deletions
+29
View File
@@ -241,6 +241,35 @@ final class AccessTabTest extends TestCase
self::assertStringContainsString('confirm(', $html);
}
/** The admin script asks before a save that leaves nobody, or the admin themselves, with access. */
public function testRolesFormCarriesTheConfirmationTextsAndTheOwnRoles(): void
{
$this->stubRender(['editor']);
$html = $this->renderTab([]);
self::assertStringContainsString(
'<form method="post" action="options.php" class="lit-mcp-access-form"'
. ' data-confirm-none="Keine Rolle freigegeben – niemand kann LIT MCP nutzen. Trotzdem speichern?"'
. ' data-confirm-own="Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.'
. ' Trotzdem speichern?"'
. ' data-own-roles="editor">',
$html
);
}
public function testMultisiteSuperAdminsGetNoOwnRoleConfirmation(): void
{
$this->stubRender(['editor']);
Functions\when('is_multisite')->justReturn(true);
Functions\when('is_super_admin')->justReturn(true);
$html = $this->renderTab([]);
self::assertStringContainsString('class="lit-mcp-access-form"', $html);
self::assertStringNotContainsString('data-own-roles', $html);
}
public function testRolesAreAFormTableFieldsetWithLegendAndDescription(): void
{
$this->stubRender(['editor']);
+1
View File
@@ -104,5 +104,6 @@ final class AdminNoticesTest extends TestCase
self::assertSame([], $notices->messages(false));
self::assertCount(1, $notices->messages(true));
self::assertSame([], $notices->messages(true, true), 'not where the status list already says it');
}
}
+17 -2
View File
@@ -150,8 +150,8 @@ final class DiviTabTest extends TestCase
Functions\when('rest_url')->justReturn('https://k.de/wp-json/lit-mcp/v1/divi-check');
Functions\when('wp_create_nonce')->justReturn('n');
Functions\when('wp_nonce_field')->justReturn('');
Functions\when('submit_button')->alias(static function (string $text): void {
echo '<button>' . $text . '</button>';
Functions\when('submit_button')->alias(static function (string $text, string $type = 'primary'): void {
echo '<button class="' . $type . '">' . $text . '</button>';
});
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan']);
Functions\when('date_i18n')->justReturn('01.10.2026 10:00');
@@ -173,6 +173,21 @@ final class DiviTabTest extends TestCase
self::assertStringContainsString('name="_wpnonce" value="n"', $html);
}
public function testCheckButtonSaysWhatItDoesAndLeadsWhileNothingElseCanBeDone(): void
{
$stale = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report(true, '5.2.9'));
$fresh = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report());
$released = $this->rendered(ReleaseStatus::released('5.3.0'), self::report());
foreach ([$stale, $fresh, $released] as $html) {
self::assertStringContainsString('legt eine private Testseite an', $html);
}
self::assertStringContainsString('<button class="primary">Prüfung starten</button>', $stale);
self::assertStringContainsString('<button class="secondary">Prüfung starten</button>', $fresh);
self::assertStringContainsString('<button class="primary">Divi 5.3.0 freigeben</button>', $fresh);
self::assertStringContainsString('<button class="secondary">Prüfung starten</button>', $released);
}
public function testUntestedWithAStaleReportAsksForANewCheck(): void
{
$html = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report(true, '5.2.9'));
-13
View File
@@ -48,7 +48,6 @@ final class LogListTableTest extends TestCase
return new LogListTable(
$log ?? Mockery::mock(ChangeLog::class),
$filters + ['ability' => '', 'user_id' => 0],
null,
$toolLabels,
$objects,
self::NOW
@@ -254,18 +253,6 @@ final class LogListTableTest extends TestCase
);
}
public function testTopNavRendersTheGivenFilters(): void
{
$table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0], static function (): void {
echo '<select name="ability"></select>';
});
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 rowActions(LogListTable $table, array $item): string
{
+23 -5
View File
@@ -184,15 +184,16 @@ final class LogTabTest extends TestCase
self::assertFalse($tab->savePerPage(false, 'other_per_page', '40'));
}
public function testRenderShowsFiltersAndAConfirmProtectedClearButton(): void
public function testRenderShowsTheFiltersAboveTheTableOutsideTheTableNav(): void
{
$html = $this->render(['lit/content-update', 'lit/media-upload-from-url', 'lit/x"><script>']);
self::assertStringContainsString('name="result"', $html);
self::assertStringNotContainsString('<script>', $html);
self::assertStringContainsString('lit_mcp_clear_log', $html);
self::assertStringContainsString('confirm(', $html);
self::assertStringContainsString('nonce-lit_mcp_clear_log', $html);
// Core hides ".tablenav .actions" on phones; the filters live in their own block before the table.
self::assertStringContainsString('<div class="lit-mcp-log-filters">', $html);
self::assertStringNotContainsString('alignleft actions', $html);
self::assertStringNotContainsString('lit_mcp_clear_log', $html, 'nothing to clear in an empty log');
// One intro line; the retention days link to the Limits tab.
self::assertMatchesRegularExpression('#<p class="description">[^<]*<a href="[^"]*tab=limits">90 Tagen</a>#', $html);
@@ -207,6 +208,22 @@ final class LogTabTest extends TestCase
self::assertStringNotContainsString('Filter zurücksetzen', $html, 'no reset without an active filter');
}
public function testClearButtonAppearsOnlyWithEntriesAndNamesTheirCount(): void
{
$two = $this->render([], 2);
self::assertStringContainsString('<div class="lit-mcp-clear-log">', $two);
self::assertStringContainsString('lit_mcp_clear_log', $two);
self::assertStringContainsString('confirm(', $two);
self::assertStringContainsString('nonce-lit_mcp_clear_log', $two);
self::assertStringContainsString('>Protokoll leeren (2 Einträge)</button>', $two);
self::assertStringContainsString('>Protokoll leeren (1 Eintrag)</button>', $this->render([], 1));
// The count is the whole log, not the filtered page (query() above answers with no rows).
$_GET['result'] = 'refused';
self::assertStringContainsString('>Protokoll leeren (2 Einträge)</button>', $this->render([], 2));
}
public function testToolFilterShowsHumanLabelsGroupedByIntegration(): void
{
$_GET['ability'] = 'lit/content-update';
@@ -234,7 +251,7 @@ final class LogTabTest extends TestCase
}
/** @param list<string> $abilities abilities found in the log */
private function render(array $abilities): string
private function render(array $abilities, int $total = 0): string
{
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
@@ -267,6 +284,7 @@ final class LogTabTest extends TestCase
$log = Mockery::mock(ChangeLog::class);
$log->allows('abilities')->andReturn($abilities);
$log->allows('query')->andReturn(['rows' => [], 'total' => 0]);
$log->allows('count')->andReturn($total);
ob_start();
$this->tab($log)->render();
+36 -6
View File
@@ -200,7 +200,7 @@ 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::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">',
@@ -211,6 +211,36 @@ final class ToolsTabTest extends TestCase
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']);
@@ -338,7 +368,7 @@ final class ToolsTabTest extends TestCase
$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 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);
@@ -367,11 +397,11 @@ final class ToolsTabTest extends TestCase
$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('<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">', $default);
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">', $saved);
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group closed">', $saved);
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);
}
}
+3 -1
View File
@@ -182,14 +182,16 @@ final class ChangeLogTest extends TestCase
self::assertSame([], $result['rows']);
}
public function testAbilitiesAndClear(): void
public function testAbilitiesCountAndClear(): void
{
$db = Mockery::mock(\wpdb::class)->makePartial();
$db->expects('get_col')->with('SELECT DISTINCT ability FROM wp_lit_mcp_log ORDER BY ability')->andReturn(['lit/a']);
$db->expects('get_var')->with('SELECT COUNT(*) FROM wp_lit_mcp_log')->andReturn('7');
$db->expects('query')->with('DELETE FROM wp_lit_mcp_log')->andReturn(4);
$log = new ChangeLog($db);
self::assertSame(['lit/a'], $log->abilities());
self::assertSame(7, $log->count());
$log->clear();
}
}