feat: lit/tb-list and the tool group "Divi Theme Builder"

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 15:57:45 +02:00
co-authored by Claude Opus 5.5
parent ac69fdf113
commit 54922a9f7e
6 changed files with 455 additions and 1 deletions
@@ -69,6 +69,25 @@ final class DiviIntegrationTest extends TestCase
}
}
public function testProvidesTheThemeBuilderGroup(): void
{
Functions\when('__')->returnArg();
$group = (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->themeBuilder();
self::assertSame('divi-theme-builder', $group->id());
self::assertSame('Divi Theme Builder', $group->label());
self::assertFalse($group->isAvailable(), 'no Divi classes in unit tests');
self::assertSame(
['lit/tb-list'],
array_map(static fn (Ability $ability): string => $ability->name(), $group->abilities())
);
foreach ($group->abilities() as $ability) {
self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name());
self::assertNotNull($ability->outputSchema(), $ability->name());
self::assertFalse($ability->isDirect(), $ability->name());
}
}
/** Ruling P9 (E9): lit/divi-node-copy follows lit/divi-insert's Tools-tab state. */
public function testNodeCopyFollowsLitDiviInsert(): void
{
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\Divi\ThemeBuilder\TbListAbility;
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
use LIT\MCP\Tests\Unit\TestCase;
final class TbListAbilityTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
LibraryFixtures::given([]);
Functions\when('get_the_title')->alias(static fn (int $id): string => '[Fixture] Layout ' . $id);
}
/** @param list<string> $deniedRoles */
private static function ability(DiviThemeBuilder $store, array $deniedRoles = [], bool $visible = true): TbListAbility
{
return new TbListAbility(
ThemeBuilderFixtures::access($deniedRoles, true, $store, $visible),
new TemplateConditions($store)
);
}
/** @param list<int> $notEditable Post IDs without edit_post. */
private static function denyEditing(array $notEditable): void
{
Functions\when('current_user_can')->alias(
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post'
&& in_array((int) ($args[0] ?? 0), $notEditable, true))
);
}
public function testListsTemplatesWithLayoutsAndLabels(): void
{
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)]);
$result = self::ability($store)->execute([]);
self::assertSame(1064, $result['theme_builder_id']);
self::assertFalse($result['draft_pending']);
self::assertSame([1065, 1066], array_column($result['templates'], 'id'));
$template = $result['templates'][1];
self::assertFalse($template['enabled']);
self::assertFalse($template['live']);
self::assertSame(ThemeBuilderFixtures::template(1066)->rev(), $template['template_rev']);
self::assertSame(['Bestimmte Seiten: 1053'], $template['use_on_labels']);
self::assertSame([], $template['exclude_from_labels']);
self::assertSame('[Fixture] Layout 1062', $template['layouts']['body']['title']);
self::assertSame([1065], $template['layouts']['header']['shared_with'], 'the global header');
self::assertSame([], $template['layouts']['body']['shared_with']);
self::assertSame('', $result['templates'][0]['layouts']['body']['title'], 'no layout');
self::assertSame([], $result['detached']);
self::assertSame([], $result['notes']);
self::assertArrayNotHasKey('conditions', $result);
}
public function testReportsDetachedAndMarkedTemplates(): void
{
$store = ThemeBuilderFixtures::store(
[ThemeBuilderFixtures::template(1065)],
0,
[ThemeBuilderFixtures::template(1066, ['attached' => false, 'marked_unused' => '2026-09-27 10:00:00'])]
);
$result = self::ability($store)->execute([]);
self::assertSame([1066], array_column($result['detached'], 'id'));
self::assertSame('2026-09-27 10:00:00', $result['detached'][0]['marked_unused']);
self::assertFalse($result['detached'][0]['attached']);
self::assertSame([1065], $result['detached'][0]['layouts']['header']['shared_with']);
self::assertStringContainsString('reattach', implode(' ', $result['notes']));
self::assertSame([1066], array_column(self::ability($store)->execute(['template_id' => 1066])['detached'], 'id'));
}
public function testOneTemplateTheDraftAndTheConditionCatalog(): void
{
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)], 999);
$result = self::ability($store)->execute(['template_id' => 1066, 'conditions' => true]);
self::assertSame([1066], array_column($result['templates'], 'id'));
self::assertTrue($result['draft_pending']);
self::assertStringContainsString('Entwurf', implode(' ', $result['notes']));
self::assertContains('singular:post_type:lit_person:all', array_column($result['conditions'], 'id'));
self::assertToolError(self::ability($store)->execute(['template_id' => 4711]), 'not_found');
}
public function testNeedsTheThemeBuilderRole(): void
{
$ability = self::ability(ThemeBuilderFixtures::store(), ['theme_builder']);
self::assertToolError($ability->permission([]), 'forbidden');
self::assertToolError($ability->execute([]), 'forbidden');
self::assertTrue($ability->annotations()['readonly']);
self::assertFalse($ability->isDirect());
}
/** Ruling Q15: without edit_theme_options the Theme Builder is closed. */
public function testNeedsEditThemeOptions(): void
{
LibraryFixtures::given([], ['edit_theme_options']);
self::assertToolError(self::ability(ThemeBuilderFixtures::store())->execute([]), 'forbidden');
}
/**
* Ruling Q8/B11: templates the user may not edit do not appear anywhere – not in the lists, not in
* shared_with; asking for one reads exactly like a missing ID (P12).
*/
public function testHidesTemplatesTheUserCannotEdit(): void
{
self::denyEditing([1065, 1067]);
$store = ThemeBuilderFixtures::store(
[ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)],
0,
[ThemeBuilderFixtures::template(1067, ['attached' => false])]
);
$result = self::ability($store)->execute([]);
self::assertSame([1066], array_column($result['templates'], 'id'));
self::assertSame([], $result['detached']);
self::assertSame([], $result['templates'][0]['layouts']['header']['shared_with']);
self::assertSame([], $result['notes'], 'no hint about hidden detached templates');
$encoded = (string) json_encode($result);
self::assertStringNotContainsString('1065', $encoded);
self::assertStringNotContainsString('1067', $encoded);
$hidden = self::ability($store)->execute(['template_id' => 1065]);
$missing = self::ability($store)->execute(['template_id' => 4711]);
self::assertToolError($hidden, 'not_found');
self::assertSame(
$missing->get_error_message(),
str_replace('1065', '4711', $hidden->get_error_message())
);
}
/** Ruling Q8/B11: layout titles only for layouts the user may edit. */
public function testLayoutTitlesOnlyForEditableLayouts(): void
{
self::denyEditing([1062]);
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066)]);
$layouts = self::ability($store)->execute([])['templates'][0]['layouts'];
self::assertSame('', $layouts['body']['title']);
self::assertSame('[Fixture] Layout 1061', $layouts['header']['title']);
}
public function testConditionLabelsNeverNameObjectTitles(): void
{
Functions\when('get_the_title')->justReturn('Geheime Seite');
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066, [
'layouts' => [
'header' => ['id' => 0, 'enabled' => true],
'body' => ['id' => 0, 'enabled' => true],
'footer' => ['id' => 0, 'enabled' => true],
],
'exclude_from' => ['singular:post_type:page:children:id:7', 'unbekannt:x'],
])]);
$result = self::ability($store)->execute([]);
self::assertSame(
['Unterseiten bestimmter Seiten: 7', 'unbekannt:x'],
$result['templates'][0]['exclude_from_labels']
);
self::assertStringNotContainsString('Geheime Seite', (string) json_encode($result));
}
}