feat: Protokoll tab as a core-style list table

The change summary is the wide primary column (key "title", so it cannot
be hidden) with the tool's human label below and row actions "Revision
ansehen" / "Objekt öffnen". Objects get human names ("Seite: Kontakt",
"Menü: …", "Einstellungen: Titel der Website"; deleted/trashed marked),
primed per page with one query each for posts, menus and users. Times are
relative within 24 h. Non-ok results show a badge and a marked row instead
of an Ergebnis column. Page size and columns via Screen Options; the tool
filter shows labels grouped by integration, dates have visible labels,
and an active filter offers "Filter zurücksetzen" (also in the empty state).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 19:02:47 +02:00
co-authored by Claude Opus 5.5
parent 22c4cc1264
commit 0f23192873
9 changed files with 1124 additions and 351 deletions
+167
View File
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Admin;
use Brain\Monkey\Functions;
use LIT\MCP\Admin\LogObjects;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
use WP_Term;
final class LogObjectsTest extends TestCase
{
/** @var array<int, WP_Post> */
private array $posts = [];
protected function setUp(): void
{
parent::setUp();
Functions\stubEscapeFunctions();
Functions\stubTranslationFunctions();
Functions\when('post_type_exists')->alias(static fn (string $type): bool => in_array($type, ['page', 'post', 'attachment'], true));
Functions\when('get_post_type_object')->alias(static fn (string $type): object => (object) [
'labels' => (object) ['singular_name' => ['page' => 'Seite', 'post' => 'Beitrag'][$type] ?? $type],
]);
Functions\when('get_post')->alias(fn ($id) => $this->posts[(int) $id] ?? null);
Functions\when('get_edit_post_link')->alias(
static fn ($id, $context = 'display'): string => 'https://kunde.de/wp-admin/post.php?post=' . $id . '&action=edit'
);
Functions\when('admin_url')->alias(static fn (string $path = ''): string => 'https://kunde.de/wp-admin/' . $path);
Functions\when('current_user_can')->justReturn(true);
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']);
}
public function testAPageShowsItsTypeLabelAndLinkedTitle(): void
{
$this->posts[834] = new WP_Post(['ID' => 834, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => '[Test] <b>Kontakt</b>']);
$object = (new LogObjects())->describe('page', '834');
self::assertSame(
'Seite: <a href="https://kunde.de/wp-admin/post.php?post=834&action=edit">[Test] &lt;b&gt;Kontakt&lt;/b&gt;</a>',
$object['html']
);
self::assertSame('https://kunde.de/wp-admin/post.php?post=834&action=edit', $object['url']);
}
public function testAnUntitledPostSaysSo(): void
{
$this->posts[5] = new WP_Post(['ID' => 5, 'post_type' => 'post', 'post_status' => 'draft', 'post_title' => '']);
self::assertStringContainsString('>(kein Titel)</a>', (new LogObjects())->describe('post', '5')['html']);
}
public function testNoLinkWhenTheUserMayNotEditThePost(): void
{
Functions\when('get_edit_post_link')->justReturn(null);
$this->posts[834] = new WP_Post(['ID' => 834, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Kontakt']);
self::assertSame(['html' => 'Seite: Kontakt', 'url' => ''], (new LogObjects())->describe('page', '834'));
}
public function testATrashedPostIsNamedButNotLinked(): void
{
$this->posts[783] = new WP_Post(['ID' => 783, 'post_type' => 'page', 'post_status' => 'trash', 'post_title' => 'LIT Smoke Seite']);
self::assertSame(
['html' => 'Seite: LIT Smoke Seite (im Papierkorb)', 'url' => ''],
(new LogObjects())->describe('page', '783')
);
}
public function testADeletedPostShowsItsIdAsDeleted(): void
{
self::assertSame(['html' => 'Seite: #834 (gelöscht)', 'url' => ''], (new LogObjects())->describe('page', '834'));
}
public function testAnAttachmentIsAMediumLinkedToTheMediaEditScreen(): void
{
$this->posts[830] = new WP_Post(['ID' => 830, 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_title' => 'Massivholzmoebel']);
$object = (new LogObjects())->describe('attachment', '830');
self::assertSame(
'Medium: <a href="https://kunde.de/wp-admin/post.php?post=830&action=edit">Massivholzmoebel</a>',
$object['html']
);
self::assertSame('Medium: #799 (gelöscht)', (new LogObjects())->describe('attachment', '799')['html']);
}
public function testANavMenuIsLinkedToTheMenusScreen(): void
{
Functions\when('get_terms')->justReturn([new WP_Term(['term_id' => 21, 'name' => 'Haupt<menü>', 'taxonomy' => 'nav_menu'])]);
$objects = new LogObjects();
$objects->prime([['object_type' => 'nav_menu', 'object_id' => '21'], ['object_type' => 'nav_menu', 'object_id' => '22']]);
self::assertSame(
[
'html' => 'Menü: <a href="https://kunde.de/wp-admin/nav-menus.php?action=edit&menu=21">Haupt&lt;menü&gt;</a>',
'url' => 'https://kunde.de/wp-admin/nav-menus.php?action=edit&menu=21',
],
$objects->describe('nav_menu', '21')
);
self::assertSame(['html' => 'Menü: #22 (gelöscht)', 'url' => ''], $objects->describe('nav_menu', '22'));
}
public function testANavMenuIsNotLinkedWithoutTheMenuCapability(): void
{
Functions\when('current_user_can')->justReturn(false);
Functions\when('get_terms')->justReturn([new WP_Term(['term_id' => 21, 'name' => 'Hauptmenü', 'taxonomy' => 'nav_menu'])]);
$objects = new LogObjects();
$objects->prime([['object_type' => 'nav_menu', 'object_id' => '21']]);
self::assertSame(['html' => 'Menü: Hauptmenü', 'url' => ''], $objects->describe('nav_menu', '21'));
}
public function testAMenuLocationShowsItsRegisteredLabel(): void
{
self::assertSame(['html' => 'Menüposition: Hauptmenü', 'url' => ''], (new LogObjects())->describe('menu_location', 'primary-menu'));
self::assertSame(
['html' => 'Menüposition: <code>footer&lt;x&gt;</code>', 'url' => ''],
(new LogObjects())->describe('menu_location', 'footer<x>')
);
}
public function testOptionsShowHumanLabelsAndUnknownKeysAsCode(): void
{
self::assertSame(
['html' => 'Einstellungen: Titel der Website, Untertitel', 'url' => ''],
(new LogObjects())->describe('option', 'blogname,blogdescription')
);
self::assertSame(
['html' => 'Einstellung: <code>foo&lt;b&gt;</code>', 'url' => ''],
(new LogObjects())->describe('option', 'foo<b>')
);
}
public function testAnUnknownObjectTypeIsShownAsCode(): void
{
self::assertSame(['html' => '<code>shop_thing 42</code>', 'url' => ''], (new LogObjects())->describe('shop_thing', '42'));
self::assertSame(['html' => '–', 'url' => ''], (new LogObjects())->describe('', ''));
}
public function testPrimeLoadsAllPostsAndMenusOfThePageInOneGoEach(): void
{
Functions\expect('_prime_post_caches')->once()->with([834, 830], false, false);
Functions\expect('get_terms')->once()->with(['taxonomy' => 'nav_menu', 'include' => [21], 'hide_empty' => false])->andReturn([]);
(new LogObjects())->prime([
['object_type' => 'page', 'object_id' => '834'],
['object_type' => 'attachment', 'object_id' => '830'],
['object_type' => 'page', 'object_id' => '834'],
['object_type' => 'nav_menu', 'object_id' => '21'],
['object_type' => 'option', 'object_id' => 'blogname'],
['object_type' => 'page', 'object_id' => 'x'],
]);
}
public function testPrimeQueriesNothingWithoutPostsOrMenus(): void
{
Functions\expect('_prime_post_caches')->never();
Functions\expect('get_terms')->never();
(new LogObjects())->prime([['object_type' => 'option', 'object_id' => 'blogname']]);
}
}