feat: menu list, get, create and location tools
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:
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\FollowUp;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* lit/menu-assign-location: assigns a menu to a theme location (menu_id 0 removes
|
||||
* the assignment). Idempotent: assigning the same menu again, or removing an
|
||||
* already-empty location, changes nothing but still succeeds.
|
||||
*/
|
||||
final class MenuAssignLocationAbility implements Ability
|
||||
{
|
||||
public function __construct(private ChangeLog $log)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/menu-assign-location';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Menü-Position zuweisen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Weist einer Theme-Position (siehe lit-menu-list) ein Menü zu; menu_id 0 entfernt die Zuweisung. '
|
||||
. 'Das vorher zugewiesene Menü steht in previous_menu_id.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'location' => ['type' => 'string', 'minLength' => 1],
|
||||
'menu_id' => ['type' => 'integer', 'minimum' => 0],
|
||||
],
|
||||
'required' => ['location', 'menu_id'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema(['previous_menu_id' => ['type' => ['integer', 'null']]], ['previous_menu_id']);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => false, 'destructive' => false, 'idempotent' => true];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return current_user_can('edit_theme_options')
|
||||
? true
|
||||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$location = (string) ($input['location'] ?? '');
|
||||
$menuId = (int) ($input['menu_id'] ?? 0);
|
||||
$registered = MenuLocations::registered();
|
||||
if (! array_key_exists($location, $registered)) {
|
||||
return Errors::invalidInput(
|
||||
'Unbekannte Menü-Position.',
|
||||
['location' => 'verfügbar: ' . implode(', ', array_keys($registered))]
|
||||
);
|
||||
}
|
||||
|
||||
if ($menuId > 0 && ! is_object(wp_get_nav_menu_object($menuId))) {
|
||||
return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.');
|
||||
}
|
||||
|
||||
$previous = MenuLocations::assigned()[$location] ?? 0;
|
||||
MenuLocations::assign($location, $menuId);
|
||||
|
||||
$action = $menuId > 0 ? 'assigned' : 'unassigned';
|
||||
$summary = $menuId > 0
|
||||
? sprintf('Menü %d der Position %s zugewiesen', $menuId, $location)
|
||||
: sprintf('Zuweisung der Position %s entfernt', $location);
|
||||
|
||||
// The assignment is saved – a change log failure from here on is logged and reported as a warning only.
|
||||
$warnings = [];
|
||||
$logId = FollowUp::run(
|
||||
fn () => $this->log->record($this->name(), 'menu_location', $location, $summary),
|
||||
$this->name() . ' change log',
|
||||
'Änderungsprotokoll konnte nicht geschrieben werden; die Zuweisung ist gespeichert.',
|
||||
$warnings
|
||||
);
|
||||
|
||||
return ToolResult::ok($summary . '.')
|
||||
->changed($location, 'menu_location', $action, $registered[$location])
|
||||
->logId(is_int($logId) ? $logId : null)
|
||||
->warnings($warnings)
|
||||
->with('previous_menu_id', $previous > 0 ? $previous : null)
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Abilities\FollowUp;
|
||||
use LIT\MCP\Abilities\ToolResult;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* lit/menu-create: creates an empty menu and optionally assigns it to a theme
|
||||
* location. Menu items are added afterwards via lit/menu-set-items (Task 14).
|
||||
*/
|
||||
final class MenuCreateAbility implements Ability
|
||||
{
|
||||
public function __construct(private ChangeLog $log)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/menu-create';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Menü anlegen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Legt ein leeres Menü an und weist es optional einer Theme-Position zu. Einträge danach mit '
|
||||
. 'lit-menu-set-items setzen.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string', 'minLength' => 1],
|
||||
'location' => ['type' => 'string', 'description' => 'Theme-Position aus lit-menu-list.'],
|
||||
],
|
||||
'required' => ['name'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return ToolResult::schema(['menu_id' => ['type' => 'integer']], ['menu_id']);
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => false, 'destructive' => false, 'idempotent' => false];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return current_user_can('edit_theme_options')
|
||||
? true
|
||||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$name = trim((string) ($input['name'] ?? ''));
|
||||
$location = isset($input['location']) ? (string) $input['location'] : '';
|
||||
if ($location !== '' && ! array_key_exists($location, MenuLocations::registered())) {
|
||||
return Errors::invalidInput(
|
||||
'Unbekannte Menü-Position.',
|
||||
['location' => 'verfügbar: ' . implode(', ', array_keys(MenuLocations::registered()))]
|
||||
);
|
||||
}
|
||||
|
||||
$menuId = wp_create_nav_menu($name);
|
||||
if ($menuId instanceof WP_Error) {
|
||||
return Errors::invalidInput(
|
||||
'Menü konnte nicht angelegt werden: ' . $menuId->get_error_message(),
|
||||
['name' => 'bereits vergeben oder ungültig'],
|
||||
'Vorhandene Menüs über lit-menu-list abrufen.'
|
||||
);
|
||||
}
|
||||
|
||||
$menuId = (int) $menuId;
|
||||
if ($location !== '') {
|
||||
MenuLocations::assign($location, $menuId);
|
||||
}
|
||||
|
||||
// The menu is saved – a change log failure from here on is logged and reported as a warning only.
|
||||
$warnings = [];
|
||||
$summary = sprintf('Menü "%s" angelegt', $name)
|
||||
. ($location !== '' ? sprintf(' (Position %s)', $location) : '');
|
||||
$logId = FollowUp::run(
|
||||
fn () => $this->log->record($this->name(), 'nav_menu', (string) $menuId, $summary),
|
||||
$this->name() . ' change log',
|
||||
'Änderungsprotokoll konnte nicht geschrieben werden; das Menü ist angelegt.',
|
||||
$warnings
|
||||
);
|
||||
|
||||
return ToolResult::ok($summary . sprintf(', ID %d.', $menuId))
|
||||
->changed((string) $menuId, 'nav_menu', 'created', $name)
|
||||
->logId(is_int($logId) ? $logId : null)
|
||||
->warnings($warnings)
|
||||
->with('menu_id', $menuId)
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* lit/menu-get: a menu as a tree (P4 ruling: same node shape lit/menu-set-items,
|
||||
* Task 14, accepts as input, so the result can be edited and sent back as-is).
|
||||
*/
|
||||
final class MenuGetAbility implements Ability
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/menu-get';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Menü lesen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Liefert ein Menü als Baum (id, title, type post/taxonomy/custom, object/object_id, url, '
|
||||
. 'children). object_id und url sind bei post/taxonomy-Einträgen nur informativ (WordPress leitet '
|
||||
. 'sie aus dem verlinkten Objekt ab); nur bei custom-Einträgen ist url maßgeblich. Der Baum kann '
|
||||
. 'bearbeitet an lit-menu-set-items zurückgegeben werden.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => ['menu_id' => ['type' => 'integer', 'minimum' => 1]],
|
||||
'required' => ['menu_id'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'locations' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
'items' => ['type' => 'array', 'items' => MenuTree::nodeSchema()],
|
||||
],
|
||||
'required' => ['id', 'name', 'locations', 'items'],
|
||||
];
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return current_user_can('edit_theme_options')
|
||||
? true
|
||||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$menuId = (int) ($input['menu_id'] ?? 0);
|
||||
$menu = wp_get_nav_menu_object($menuId);
|
||||
if (! is_object($menu)) {
|
||||
return Errors::notFound(sprintf('Menü %d nicht gefunden.', $menuId), 'Menüs über lit-menu-list abrufen.');
|
||||
}
|
||||
|
||||
$items = wp_get_nav_menu_items($menuId);
|
||||
|
||||
return [
|
||||
'id' => (int) $menu->term_id,
|
||||
'name' => (string) $menu->name,
|
||||
'locations' => MenuLocations::of((int) $menu->term_id),
|
||||
'items' => MenuTree::build(is_array($items) ? $items : []),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use WP_Error;
|
||||
|
||||
/**
|
||||
* lit/menu-list: overview of all menus and the theme's menu locations, and which
|
||||
* menu (if any) currently sits at each location.
|
||||
*/
|
||||
final class MenuListAbility implements Ability
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/menu-list';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Menüs auflisten';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Listet alle Menüs mit Anzahl der Einträge sowie die Menü-Positionen des Themes und welches '
|
||||
. 'Menü dort zugewiesen ist.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return ['type' => 'object', 'properties' => [], 'additionalProperties' => false];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'menus' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'locations' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
],
|
||||
'required' => ['menus', 'locations'],
|
||||
];
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return current_user_can('edit_theme_options')
|
||||
? true
|
||||
: Errors::forbidden('Keine Berechtigung, Menüs zu bearbeiten (edit_theme_options).');
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$menus = [];
|
||||
foreach ((array) wp_get_nav_menus() as $menu) {
|
||||
$menus[] = [
|
||||
'id' => (int) $menu->term_id,
|
||||
'name' => (string) $menu->name,
|
||||
'slug' => (string) $menu->slug,
|
||||
'count' => (int) $menu->count,
|
||||
'locations' => MenuLocations::of((int) $menu->term_id),
|
||||
];
|
||||
}
|
||||
|
||||
$assigned = MenuLocations::assigned();
|
||||
$locations = [];
|
||||
foreach (MenuLocations::registered() as $location => $description) {
|
||||
$menuId = $assigned[$location] ?? 0;
|
||||
$locations[] = [
|
||||
'location' => (string) $location,
|
||||
'description' => $description,
|
||||
'menu_id' => $menuId > 0 ? $menuId : null,
|
||||
];
|
||||
}
|
||||
|
||||
return ['menus' => $menus, 'locations' => $locations];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
/**
|
||||
* Theme menu locations (theme mod nav_menu_locations).
|
||||
*/
|
||||
final class MenuLocations
|
||||
{
|
||||
/** @return array<string, string> Location slug => description. */
|
||||
public static function registered(): array
|
||||
{
|
||||
return array_map('strval', (array) get_registered_nav_menus());
|
||||
}
|
||||
|
||||
/** @return array<string, int> Location slug => menu ID. */
|
||||
public static function assigned(): array
|
||||
{
|
||||
$locations = get_nav_menu_locations();
|
||||
|
||||
return is_array($locations) ? array_map('intval', $locations) : [];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function of(int $menuId): array
|
||||
{
|
||||
return array_map('strval', array_keys(array_filter(
|
||||
self::assigned(),
|
||||
static fn (int $id): bool => $id === $menuId
|
||||
)));
|
||||
}
|
||||
|
||||
public static function assign(string $location, int $menuId): void
|
||||
{
|
||||
$locations = self::assigned();
|
||||
if ($menuId > 0) {
|
||||
$locations[$location] = $menuId;
|
||||
} else {
|
||||
unset($locations[$location]);
|
||||
}
|
||||
|
||||
set_theme_mod('nav_menu_locations', $locations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\WordPress\Menus;
|
||||
|
||||
/**
|
||||
* Turns the flat wp_get_nav_menu_items() list into a nested tree. The node shape
|
||||
* (node()) is deliberately the same shape lit/menu-set-items (Task 14) accepts as
|
||||
* input, minus "children": a lit/menu-get result can be edited and sent back as-is.
|
||||
* "url" is informational for object-linked items (post/taxonomy) – WordPress derives
|
||||
* it from object/object_id and ignores it on write; it is only authoritative for
|
||||
* "custom" items.
|
||||
*/
|
||||
final class MenuTree
|
||||
{
|
||||
/**
|
||||
* @param list<object> $items Sorted by menu_order (wp_get_nav_menu_items default).
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public static function build(array $items): array
|
||||
{
|
||||
$nodes = [];
|
||||
foreach ($items as $item) {
|
||||
$nodes[(int) $item->ID] = self::node($item);
|
||||
}
|
||||
|
||||
$children = [];
|
||||
foreach ($items as $item) {
|
||||
$id = (int) $item->ID;
|
||||
$parent = (int) $item->menu_item_parent;
|
||||
$children[$parent !== $id && isset($nodes[$parent]) ? $parent : 0][] = $id;
|
||||
}
|
||||
|
||||
return self::attach(0, $nodes, $children);
|
||||
}
|
||||
|
||||
/** @return array{id: int, title: string, type: string, object: string, object_id: int, url: string} */
|
||||
public static function node(object $item): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $item->ID,
|
||||
'title' => (string) $item->title,
|
||||
'type' => match ((string) $item->type) {
|
||||
'post_type' => 'post',
|
||||
'taxonomy' => 'taxonomy',
|
||||
default => 'custom',
|
||||
},
|
||||
'object' => (string) $item->object,
|
||||
'object_id' => (int) $item->object_id,
|
||||
'url' => (string) $item->url,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> JSON schema of one tree node (children not validated deeper). */
|
||||
public static function nodeSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'integer', 'description' => 'Menu-item ID (0 or missing for a new item).'],
|
||||
'title' => ['type' => 'string'],
|
||||
'type' => [
|
||||
'type' => 'string',
|
||||
'enum' => ['post', 'taxonomy', 'custom'],
|
||||
'description' => 'post/taxonomy link to object_id; custom uses url.',
|
||||
],
|
||||
'object' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Informational: WP object subtype (e.g. "page", "category", "custom").',
|
||||
],
|
||||
'object_id' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'Informational for post/taxonomy items; ignored for custom items.',
|
||||
],
|
||||
'url' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Informational for post/taxonomy items (derived by WordPress); '
|
||||
. 'authoritative only for custom items.',
|
||||
],
|
||||
'children' => ['type' => 'array'],
|
||||
],
|
||||
'required' => ['id', 'title', 'type', 'children'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $nodes
|
||||
* @param array<int, list<int>> $children
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function attach(int $parent, array $nodes, array $children): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($children[$parent] ?? [] as $id) {
|
||||
$result[] = array_merge($nodes[$id], ['children' => self::attach($id, $nodes, $children)]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ use LIT\MCP\Integrations\WordPress\Content\ContentTypesAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Content\ContentUpdateAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Content\PostPresenter;
|
||||
use LIT\MCP\Integrations\WordPress\Content\PostWriter;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuAssignLocationAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuCreateAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuGetAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionRestoreAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Revisions\RevisionsListAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
@@ -66,6 +70,10 @@ final class WordPressIntegration implements Integration
|
||||
new PostReplaceWithCopyAbility($this->schema, $cloner, $this->log),
|
||||
new RevisionsListAbility($this->schema),
|
||||
new RevisionRestoreAbility($this->schema, $this->log),
|
||||
new MenuListAbility(),
|
||||
new MenuGetAbility(),
|
||||
new MenuCreateAbility($this->log),
|
||||
new MenuAssignLocationAbility($this->log),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuAssignLocationAbility;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class MenuAssignLocationAbilityTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']);
|
||||
Functions\when('get_nav_menu_locations')->justReturn(['primary-menu' => 3]);
|
||||
Functions\when('wp_get_nav_menu_object')->alias(
|
||||
static fn (int $id) => $id === 8 ? (object) ['term_id' => 8, 'name' => 'Neu'] : false
|
||||
);
|
||||
}
|
||||
|
||||
public function testAssignsAndReportsThePreviousMenu(): void
|
||||
{
|
||||
Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', ['primary-menu' => 8]);
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('record')->andReturn(6);
|
||||
|
||||
$result = (new MenuAssignLocationAbility($log))->execute(['location' => 'primary-menu', 'menu_id' => 8]);
|
||||
|
||||
self::assertSame(3, $result['previous_menu_id']);
|
||||
self::assertSame('assigned', $result['changed'][0]['action']);
|
||||
}
|
||||
|
||||
public function testZeroRemovesTheAssignment(): void
|
||||
{
|
||||
Functions\expect('set_theme_mod')->once()->with('nav_menu_locations', []);
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('record')->andReturn(7);
|
||||
|
||||
$result = (new MenuAssignLocationAbility($log))->execute(['location' => 'primary-menu', 'menu_id' => 0]);
|
||||
|
||||
self::assertSame('unassigned', $result['changed'][0]['action']);
|
||||
}
|
||||
|
||||
public function testUnknownMenuIsNotFound(): void
|
||||
{
|
||||
Functions\expect('set_theme_mod')->never();
|
||||
|
||||
$result = (new MenuAssignLocationAbility(Mockery::mock(ChangeLog::class)))
|
||||
->execute(['location' => 'primary-menu', 'menu_id' => 99]);
|
||||
|
||||
self::assertSame('lit_not_found', $result->get_error_code());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\ChangeLog\ChangeLog;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuCreateAbility;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use WP_Error;
|
||||
|
||||
final class MenuCreateAbilityTest extends TestCase
|
||||
{
|
||||
public function testUnknownLocationIsRejectedBeforeCreating(): void
|
||||
{
|
||||
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']);
|
||||
Functions\expect('wp_create_nav_menu')->never();
|
||||
|
||||
$ability = new MenuCreateAbility(Mockery::mock(ChangeLog::class));
|
||||
$result = $ability->execute(['name' => 'Neu', 'location' => 'nope']);
|
||||
|
||||
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||||
self::assertStringContainsString('primary-menu', $result->get_error_message());
|
||||
}
|
||||
|
||||
public function testExistingNameIsAValidationError(): void
|
||||
{
|
||||
Functions\when('wp_create_nav_menu')->justReturn(
|
||||
new WP_Error('menu_exists', 'Der Menüname existiert bereits.')
|
||||
);
|
||||
|
||||
$result = (new MenuCreateAbility(Mockery::mock(ChangeLog::class)))->execute(['name' => 'Hauptmenü']);
|
||||
|
||||
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testCreatesAndAssignsTheLocation(): void
|
||||
{
|
||||
Functions\when('get_registered_nav_menus')->justReturn(['primary-menu' => 'Hauptmenü']);
|
||||
Functions\expect('wp_create_nav_menu')->once()->with('Neu')->andReturn(8);
|
||||
Functions\when('get_nav_menu_locations')->justReturn(['footer-menu' => 2]);
|
||||
Functions\expect('set_theme_mod')->once()->with(
|
||||
'nav_menu_locations',
|
||||
['footer-menu' => 2, 'primary-menu' => 8]
|
||||
);
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('record')
|
||||
->with('lit/menu-create', 'nav_menu', '8', 'Menü "Neu" angelegt (Position primary-menu)')
|
||||
->andReturn(5);
|
||||
|
||||
$result = (new MenuCreateAbility($log))->execute(['name' => 'Neu', 'location' => 'primary-menu']);
|
||||
|
||||
self::assertSame(8, $result['menu_id']);
|
||||
self::assertSame('created', $result['changed'][0]['action']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
||||
|
||||
/**
|
||||
* Shared wp_get_nav_menu_items()-shaped fixture, reused by every menu test
|
||||
* (tree building, read/write abilities, and the Task 14 menu-set-items tests).
|
||||
*/
|
||||
trait MenuFixtures
|
||||
{
|
||||
public static function item(
|
||||
int $id,
|
||||
int $parent,
|
||||
string $title,
|
||||
string $type = 'post_type',
|
||||
string $url = ''
|
||||
): object {
|
||||
return (object) [
|
||||
'ID' => $id,
|
||||
'menu_item_parent' => (string) $parent,
|
||||
'title' => $title,
|
||||
'post_title' => $type === 'custom' ? $title : '',
|
||||
'type' => $type,
|
||||
'object' => $type === 'custom' ? 'custom' : 'page',
|
||||
'object_id' => (string) ($type === 'custom' ? $id : $id + 100),
|
||||
'url' => $url,
|
||||
'description' => '',
|
||||
'attr_title' => '',
|
||||
'target' => '',
|
||||
'classes' => [''],
|
||||
'xfn' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuGetAbility;
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuListAbility;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class MenuReadAbilitiesTest extends TestCase
|
||||
{
|
||||
use MenuFixtures;
|
||||
|
||||
public function testListShowsMenusAndLocations(): void
|
||||
{
|
||||
Functions\when('wp_get_nav_menus')->justReturn([
|
||||
(object) ['term_id' => 3, 'name' => 'Hauptmenü', 'slug' => 'hauptmenue', 'count' => 4],
|
||||
]);
|
||||
Functions\when('get_registered_nav_menus')->justReturn(
|
||||
['primary-menu' => 'Hauptmenü', 'footer-menu' => 'Footer']
|
||||
);
|
||||
Functions\when('get_nav_menu_locations')->justReturn(['primary-menu' => 3]);
|
||||
|
||||
$result = (new MenuListAbility())->execute([]);
|
||||
|
||||
self::assertSame(['primary-menu'], $result['menus'][0]['locations']);
|
||||
self::assertSame(
|
||||
[
|
||||
['location' => 'primary-menu', 'description' => 'Hauptmenü', 'menu_id' => 3],
|
||||
['location' => 'footer-menu', 'description' => 'Footer', 'menu_id' => null],
|
||||
],
|
||||
$result['locations']
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetReturnsTheTreeOrNotFound(): void
|
||||
{
|
||||
Functions\when('wp_get_nav_menu_object')->alias(
|
||||
static fn (int $id) => $id === 3 ? (object) ['term_id' => 3, 'name' => 'Hauptmenü'] : false
|
||||
);
|
||||
Functions\when('wp_get_nav_menu_items')->justReturn([self::item(10, 0, 'Start')]);
|
||||
Functions\when('get_nav_menu_locations')->justReturn([]);
|
||||
$ability = new MenuGetAbility();
|
||||
|
||||
$result = $ability->execute(['menu_id' => 3]);
|
||||
|
||||
self::assertSame('Hauptmenü', $result['name']);
|
||||
self::assertSame('Start', $result['items'][0]['title']);
|
||||
self::assertSame('lit_not_found', $ability->execute(['menu_id' => 9])->get_error_code());
|
||||
}
|
||||
|
||||
public function testReadToolsNeedThemeOptionRights(): void
|
||||
{
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
|
||||
self::assertSame('lit_forbidden', (new MenuListAbility())->permission([])->get_error_code());
|
||||
self::assertSame('lit_forbidden', (new MenuGetAbility())->permission(['menu_id' => 3])->get_error_code());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\WordPress\Menus;
|
||||
|
||||
use LIT\MCP\Integrations\WordPress\Menus\MenuTree;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
|
||||
final class MenuTreeTest extends TestCase
|
||||
{
|
||||
use MenuFixtures;
|
||||
|
||||
public function testBuildsANestedTreeInMenuOrder(): void
|
||||
{
|
||||
$tree = MenuTree::build([
|
||||
self::item(10, 0, 'Start'),
|
||||
self::item(11, 0, 'Über uns'),
|
||||
self::item(12, 11, 'Team'),
|
||||
self::item(13, 0, 'Kontakt', 'custom', '/kontakt'),
|
||||
]);
|
||||
|
||||
self::assertSame(['Start', 'Über uns', 'Kontakt'], array_column($tree, 'title'));
|
||||
self::assertSame('Team', $tree[1]['children'][0]['title']);
|
||||
self::assertSame([], $tree[1]['children'][0]['children']);
|
||||
self::assertSame('custom', $tree[2]['type']);
|
||||
self::assertSame('post', $tree[0]['type']);
|
||||
self::assertSame(110, $tree[0]['object_id']);
|
||||
}
|
||||
|
||||
public function testOrphansAndSelfParentsLandOnTheTopLevel(): void
|
||||
{
|
||||
$tree = MenuTree::build([self::item(20, 99, 'Waise'), self::item(21, 21, 'Zirkel')]);
|
||||
|
||||
self::assertSame(['Waise', 'Zirkel'], array_column($tree, 'title'));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ final class WordPressIntegrationTest extends TestCase
|
||||
'lit/post-replace-with-copy',
|
||||
'lit/revisions-list',
|
||||
'lit/revision-restore',
|
||||
'lit/menu-list',
|
||||
'lit/menu-get',
|
||||
'lit/menu-create',
|
||||
'lit/menu-assign-location',
|
||||
];
|
||||
|
||||
public function testProvidesTheWordPressAbilitiesInOrder(): void
|
||||
|
||||
Reference in New Issue
Block a user