feat: lit/divi-presets-set – Presets anlegen und ändern mit Probelauf und Snapshot

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:47:16 +02:00
co-authored by Claude Sonnet 5.5
parent 341782a34f
commit b3c3e69a53
8 changed files with 682 additions and 0 deletions
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\Errors;
use WP_Error;
defined('ABSPATH') || exit;
/**
* Who may change the design system (spec §10): presets need edit_theme_options and Divi's role right "Visual Builder
* verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1").
*/
final class DesignRights
{
public static function presets(DesignSystemWriter $writer): ?WP_Error
{
if (! current_user_can('edit_theme_options')) {
return Errors::forbidden(
'Presets ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).'
);
}
if (! $writer->canUseVisualBuilder()) {
return Errors::forbidden(
'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine Presets.',
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
);
}
return null;
}
}
@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use Closure;
use InvalidArgumentException;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-presets-set (spec §6.3, G7): creates and changes module and option group presets – from the short and full
* form, from a module of a page (from_node), with nested group presets and as the default preset – through the
* change pipeline (dry run, design_rev, snapshot, Divi's sync, change log). New names must be free within the
* module/group (A5). The dry run lists the posts that use a changed preset or reach a new default.
*/
final class DiviPresetsSetAbility implements Ability
{
public const MAX_ITEMS = 20;
private const NAME_MAX = 100;
private const NAME_RULE = 'Name: 1–100 Zeichen, ohne HTML';
private const GROUP_DEFAULT_WARNING = 'Standard-Gruppen-Presets wirken zusätzlich auf alle Module mit dieser '
. 'Gruppe ohne eigenes Gruppen-Preset; diese Seiten sind nicht aufgelistet.';
/** @param Closure(): PresetComposer $composer */
public function __construct(
private DesignSystemWriter $writer,
private DesignChange $change,
private GlobalUsages $usages,
private DiviPageLoader $loader,
private Closure $composer
) {
}
public function name(): string
{
return 'lit/divi-presets-set';
}
public function label(): string
{
return 'Divi-Presets anlegen und ändern';
}
public function description(): string
{
return 'Legt Modul-Presets (module) und Gruppen-Presets (group) an oder ändert sie (preset = Name oder ID). '
. 'Einstellungen in attrs (Vollform wie bei lit-divi-update, mit Platzhaltern {{color:…}}/{{var:…}}) oder '
. 'aus einem Modul einer Seite (from_node: post_id + Pfad-ID); nur Gestaltung, keine Texte oder Links. '
. 'group_presets verschachtelt Gruppen-Presets, make_default macht das Preset zum Standard. Wirkt auf alle '
. 'Seiten: zuerst Probelauf (diff, betroffene Seiten, design_rev), nach Rückfrage beim '
. 'Nutzer confirm: true mit design_rev. Vorher wird ein Snapshot angelegt (lit/divi-design-restore).';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'items' => [
'type' => 'array',
'minItems' => 1,
'maxItems' => self::MAX_ITEMS,
'items' => [
'type' => 'object',
'properties' => [
'module' => ['type' => 'string'],
'group' => ['type' => 'string'],
'preset' => ['type' => 'string', 'description' => 'Zu änderndes Preset (Name oder ID).'],
'name' => ['type' => 'string', 'description' => 'Name eines neuen Presets.'],
'rename' => ['type' => 'string'],
'set' => ['type' => 'object'],
'attrs' => ['type' => 'object'],
'from_node' => [
'type' => 'object',
'properties' => ['post_id' => ['type' => 'integer'], 'id' => ['type' => 'string']],
'required' => ['post_id', 'id'],
],
'source_module' => ['type' => 'string', 'description' => 'Neues Gruppen-Preset: Modul.'],
'group_id' => ['type' => 'string'],
'group_presets' => ['type' => 'object'],
'make_default' => ['type' => 'boolean'],
],
],
],
] + DesignChange::inputProperties(),
'required' => ['items'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return DesignChange::outputSchema();
}
public function annotations(): array
{
return Annotations::destructive(false);
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
return DesignRights::presets($this->writer) ?? true;
}
public function execute(array $input): mixed
{
$refusal = DesignRights::presets($this->writer);
if ($refusal !== null) {
return $refusal;
}
$items = $input['items'] ?? null;
if (! is_array($items) || ! array_is_list($items) || $items === [] || count($items) > self::MAX_ITEMS) {
return Errors::invalidInput(
'items fehlt oder ist zu lang; nichts wurde gespeichert.',
['items' => sprintf('1 bis %d Einträge', self::MAX_ITEMS)]
);
}
return $this->change->run(
$this->name(),
new PresetArea($this->writer),
$input,
fn (array $raw): DesignPlan|WP_Error => $this->plan(PresetBook::fromDivi($raw), $items)
);
}
/** @param list<mixed> $items */
private function plan(PresetBook $before, array $items): DesignPlan|WP_Error
{
$composer = ($this->composer)();
$book = $before;
$errors = [];
$invalid = [];
$targets = [];
$warnings = [];
foreach ($items as $index => $item) {
$field = sprintf('items[%d]', $index);
if (! is_array($item)) {
$errors[$field] = 'Objekt erwartet';
continue;
}
$prepared = $this->prepare($book, $item, $field, $errors);
if ($prepared === null) {
continue;
}
[$scope, $existing, $source] = $prepared;
$result = $composer->compose($book, $scope, $existing, $item, $field, $source);
$invalid = array_merge($invalid, $result['invalid_paths']);
if ($result['errors'] !== [] || $result['invalid_paths'] !== []) {
$errors += $result['errors'];
continue;
}
$id = (string) $result['item']['id'];
$book = $book->with($scope, $result['item']);
if (($item['make_default'] ?? null) === true) {
$book = $book->withDefault($scope, $id);
if ($scope->kind === PresetScope::GROUP) {
$warnings[] = self::GROUP_DEFAULT_WARNING;
}
} elseif (($item['make_default'] ?? null) === false && $book->defaultId($scope) === $id) {
$book = $book->withDefault($scope, '');
}
if ($existing !== null || $book->defaultId($scope) === $id) {
$targets[] = ['scope' => $scope, 'id' => $id, 'default' => $before->defaultId($scope) === $id
|| $book->defaultId($scope) === $id];
}
}
if ($invalid !== []) {
return Errors::invalidAttrPath($errors);
}
if ($errors !== []) {
return Errors::invalidInput('Ungültige Preset-Angaben; nichts wurde gespeichert.', $errors);
}
$diff = PresetDiff::between($before, $book);
return new DesignPlan(
$book->data(),
$diff,
PresetDiff::summary($diff),
$targets === [] ? null : $this->usages->reportFor(new PresetUsageQuery($targets)),
array_values(array_unique($warnings))
);
}
/**
* Scope, preset to change and source node of one item; null after noting the problem in $errors.
*
* @param array<array-key, mixed> $item Names are replaced by their cleaned form.
* @param array<string, string> $errors
* @return array{PresetScope, array<string, mixed>|null, Block|null}|null
*/
private function prepare(PresetBook $book, array &$item, string $field, array &$errors): ?array
{
try {
$scope = PresetScope::fromInput($item, $field);
} catch (InvalidArgumentException $e) {
[$path, $problem] = explode(': ', $e->getMessage(), 2) + [1 => ''];
$errors[$path] = $problem;
return null;
}
$existing = null;
try {
if (is_string($item['preset'] ?? null)) {
$existing = $book->find($scope, $item['preset']);
if ($existing === null) {
$errors[$field . '.preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), $item['preset']);
return null;
}
}
} catch (InvalidArgumentException $e) {
$errors[$field . '.preset'] = $e->getMessage();
return null;
}
foreach (['name' => $existing === null, 'rename' => $existing !== null] as $key => $applies) {
if (! $applies || ! is_string($item[$key] ?? null)) {
continue;
}
$name = $this->cleanName($item[$key]);
if ($name === null) {
$errors[$field . '.' . $key] = self::NAME_RULE;
return null;
}
$item[$key] = $name;
$taken = array_diff($book->idsNamed($scope, $name), [(string) ($existing['id'] ?? '')]);
if ($taken !== []) {
$errors[$field . '.' . $key] = sprintf(
'Name schon vergeben (%s); zum Ändern preset angeben oder einen anderen Namen wählen',
implode(', ', $taken)
);
return null;
}
}
$source = $this->source($scope, $item, $field, $errors);
if ($source === false) {
return null;
}
return [$scope, $existing, $source];
}
/** Plain text of 1 to 100 characters, or null (ruling: preset names). */
private function cleanName(string $name): ?string
{
$clean = sanitize_text_field($name);
$length = function_exists('mb_strlen') ? mb_strlen($clean) : strlen($clean);
return $clean === '' || $length > self::NAME_MAX || strlen($name) > self::NAME_MAX * 4 ? null : $clean;
}
/**
* @param array<array-key, mixed> $item
* @param array<string, string> $errors
*/
private function source(PresetScope $scope, array $item, string $field, array &$errors): Block|false|null
{
$from = $item['from_node'] ?? null;
if ($from === null) {
return null;
}
if (! is_array($from) || ! is_string($from['id'] ?? null)) {
$errors[$field . '.from_node'] = 'post_id und id (Pfad-ID aus lit-divi-outline) erwartet';
return false;
}
$page = $this->loader->load($from['post_id'] ?? null, false);
if ($page instanceof WP_Error) {
$errors[$field . '.from_node.post_id'] = Errors::clientMessage($page);
return false;
}
$block = $page->index->find($from['id']);
if ($block === null) {
$errors[$field . '.from_node.id'] = sprintf('unbekannte Pfad-ID %s (lit-divi-outline)', $from['id']);
return false;
}
if ($scope->kind === PresetScope::MODULE && $block->name() !== $scope->name) {
$errors[$field . '.from_node.id'] = sprintf(
'%s ist %s, nicht %s',
$from['id'],
$block->name(),
$scope->name
);
return false;
}
return $block;
}
}
@@ -39,6 +39,30 @@ final class PresetDiff
return $rows; return $rows;
} }
/**
* One German sentence for the dry run and the change log (spec §4 step 6).
*
* @param list<array<string, mixed>> $diff
*/
public static function summary(array $diff): string
{
$count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff));
$parts = array_filter([
isset($count['added']) ? $count['added'] . ' angelegt' : null,
isset($count['changed']) ? $count['changed'] . ' geändert' : null,
isset($count['removed']) ? $count['removed'] . ' gelöscht' : null,
]);
$defaults = array_filter([
isset($count['default_set']) ? $count['default_set'] . '× gesetzt' : null,
isset($count['default_cleared']) ? $count['default_cleared'] . '× entfernt' : null,
]);
if ($defaults !== []) {
$parts[] = 'Standard ' . implode(', ', $defaults);
}
return 'Presets: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.';
}
/** /**
* @param array<string, array<string, mixed>> $was * @param array<string, array<string, mixed>> $was
* @param array<string, array<string, mixed>> $is * @param array<string, array<string, mixed>> $is
+30
View File
@@ -10,7 +10,11 @@ use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits; use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
@@ -155,6 +159,13 @@ final class DiviIntegration implements Integration
new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable), new DiviNodeCopyAbility($writer, $loader, $this->diviWritable, $this->libraryInsertable),
new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()), new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()),
new DiviPresetGetAbility($this->designWriter, $this->globalUsages()), new DiviPresetGetAbility($this->designWriter, $this->globalUsages()),
new DiviPresetsSetAbility(
$this->designWriter,
$this->designChange(),
$this->globalUsages(),
$loader,
$this->presetComposer(...)
),
]; ];
} }
@@ -217,6 +228,25 @@ final class DiviIntegration implements Integration
); );
} }
private function designChange(): DesignChange
{
return new DesignChange(new SnapshotStore(), $this->log);
}
private function presetComposer(): PresetComposer
{
$composition = new DiviComposition($this->modules, $this->design);
return new PresetComposer(
$composition->composer(),
$composition->storedGuard(),
$this->designWriter->presetAttrs(...),
$this->designWriter->groupsOf(...),
$this->modules->builderVersion(),
static fn (): int => (int) floor(microtime(true) * 1000)
);
}
private function loader(): DiviPageLoader private function loader(): DiviPageLoader
{ {
return new DiviPageLoader($this->schema, new Parser(), $this->access, $this->themeBuilderAccess); return new DiviPageLoader($this->schema, new Parser(), $this->access, $this->themeBuilderAccess);
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignRights;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DesignRightsTest extends TestCase
{
public function testPresetsNeedEditThemeOptionsAndTheVisualBuilder(): void
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('canUseVisualBuilder')->andReturn(true, false);
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
self::assertNull(DesignRights::presets($writer));
$refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden');
self::assertStringContainsString('Visual Builder', $refusal['message']);
Functions\when('current_user_can')->justReturn(false);
$refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden');
self::assertStringContainsString('edit_theme_options', $refusal['message']);
}
}
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
use LIT\MCP\Integrations\Divi\Design\PresetBook;
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
use LIT\MCP\Integrations\Divi\Design\PresetScope;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviPresetsSetAbilityTest extends TestCase
{
/** @var array<string, mixed> */
private array $stored;
private SnapshotStore $snapshots;
protected function setUp(): void
{
parent::setUp();
$this->stored = PresetBookTest::stored();
$this->snapshots = Mockery::mock(SnapshotStore::class);
Functions\when('current_user_can')->justReturn(true);
}
private function ability(bool $unfiltered = true, ?GlobalUsages $usages = null, ?DiviPageLoader $loader = null, ?DesignSystemWriter $writer = null): DiviPresetsSetAbility
{
$writer ??= $this->savingWriter();
$usages ??= Mockery::mock(GlobalUsages::class);
$usages->allows('reportFor')->andReturn(new UsageReport(0, [], 0, 0, [], 0, false));
return $this->build($writer, $usages, $loader, $unfiltered);
}
private function savingWriter(): DesignSystemWriter
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('presets')->andReturnUsing(fn (): array => $this->stored);
$writer->allows('canUseVisualBuilder')->andReturn(true);
$writer->allows('savePresets')->andReturnUsing(function (array $rest, bool $mayRemove): array {
$data = ['module' => [], 'group' => []];
foreach ($rest as $kind => $records) {
foreach ($records as $record) {
$first = $record['items'][0] ?? null;
$name = $kind === 'group' ? ($first['groupName'] ?? 'x') : ($first['moduleName'] ?? 'x');
$data[$kind][$name] = ['default' => $record['default'], 'items' => array_column($record['items'], null, 'id')];
}
}
return $this->stored = $data;
});
return $writer;
}
private function build(DesignSystemWriter $writer, GlobalUsages $usages, ?DiviPageLoader $loader, bool $unfiltered): DiviPresetsSetAbility
{
$composer = static fn (): PresetComposer => new PresetComposer(
ComposeFixture::composer($unfiltered),
new StoredNodeGuard(ComposeFixture::cleaner($unfiltered), ComposeFixture::references(),
ComposeFixture::sanitizer($unfiltered)),
static fn (string $module, array $attrs): array => ['all' => array_map(
static fn (mixed $v): mixed => is_array($v) ? array_diff_key($v, ['innerContent' => 1]) : $v,
$attrs
), 'style' => [], 'render' => []],
static fn (string $module): array => ['module.decoration.spacing' => 'divi/spacing'],
'5.15.1',
static fn (): int => 1
);
return new DiviPresetsSetAbility(
$writer,
new DesignChange($this->snapshots, new FakeChangeLog(5)),
$usages,
$loader ?? new DiviPageLoader(DiviFixtures::schema()),
$composer
);
}
private static function newButton(array $extra = []): array
{
return ['items' => [$extra + ['module' => 'button', 'name' => 'CTA dunkel',
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#111111']]]]]];
}
public function testDryRunThenConfirmCreatesThePreset(): void
{
$ability = $this->ability();
$dry = self::assertToolError($ability->execute(self::newButton()), 'confirm_required');
self::assertSame('added', $dry['diff'][0]['action']);
self::assertStringContainsString('Presets: 1 angelegt.', $dry['message']);
$this->snapshots->expects('save')->andReturn('presets-1');
$this->snapshots->expects('attachLog');
$result = $ability->execute(self::newButton() + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
self::assertTrue($result['ok']);
self::assertSame('presets-1', $result['snapshot_id']);
$names = array_column(PresetBook::fromDivi($this->stored)->items(new PresetScope('module', 'divi/button')), 'name');
self::assertContains('CTA dunkel', $names);
}
public function testExistingNameIsRefusedForANewPreset(): void
{
$result = self::assertToolError($this->ability()->execute(['items' => [
['module' => 'button', 'name' => 'CTA primär'],
]]), 'validation_failed');
self::assertStringContainsString('aaaaaaaaaa', $result['fields']['items[0].name']);
}
public function testChangingAndMakingDefault(): void
{
$dry = self::assertToolError($this->ability()->execute(['items' => [[
'module' => 'button', 'preset' => 'CTA primär', 'make_default' => true,
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#222222']]],
]]]), 'confirm_required');
self::assertSame(['changed', 'default_set'], array_column($dry['diff'], 'action'));
}
public function testUnknownPresetAndBadItemsAreCollected(): void
{
$result = self::assertToolError($this->ability()->execute(['items' => [
['module' => 'button', 'preset' => 'Fehlt'],
['name' => 'ohne Modul'],
]]), 'validation_failed');
self::assertArrayHasKey('items[0].preset', $result['fields']);
self::assertArrayHasKey('items[1]', $result['fields']);
}
public function testEditorWithoutUnfilteredHtmlIsRefusedForRawCss(): void
{
$result = $this->ability(false)->execute(['items' => [[
'module' => 'button', 'name' => 'Mit CSS',
'attrs' => ['css' => ['desktop' => ['mainElement' => 'color:red']]],
]]]);
self::assertNotTrue(is_array($result) && ($result['ok'] ?? false));
self::assertContains(Errors::codeOf($result), ['validation_failed', 'invalid_attr_path']);
}
public function testFromNodeReadsThePage(): void
{
$content = '<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
. '<!-- wp:divi/button {"button":{"decoration":{"background":{"desktop":{"value":{"color":"#abcdef"}}}}}} /-->'
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->';
DiviFixtures::givenPost($content);
$loader = new DiviPageLoader(DiviFixtures::schema());
$dry = self::assertToolError($this->ability(true, null, $loader)->execute(['items' => [[
'module' => 'button', 'name' => 'Aus Seite', 'from_node' => ['post_id' => 7, 'id' => 's0.r0.c0.m0'],
]]]), 'confirm_required');
self::assertSame('added', $dry['diff'][0]['action']);
}
public function testNamesAreCleanedAndLimited(): void
{
$ability = $this->ability();
$dry = self::assertToolError($ability->execute(self::newButton(['name' => '<b>Fett</b> Name'])), 'confirm_required');
$this->snapshots->expects('save')->andReturn('presets-1');
$this->snapshots->expects('attachLog');
$res = $ability->execute(self::newButton(['name' => '<b>Fett</b> Name']) + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
self::assertTrue($res['ok']);
$names = array_column(PresetBook::fromDivi($this->stored)->items(new PresetScope('module', 'divi/button')), 'name');
self::assertContains('Fett Name', $names);
self::assertNotContains('<b>Fett</b> Name', $names);
$long = self::assertToolError($this->ability()->execute(self::newButton(['name' => str_repeat('n', 101)])), 'validation_failed');
self::assertStringContainsString('1–100 Zeichen', $long['fields']['items[0].name']);
$empty = self::assertToolError($this->ability()->execute(['items' => [
['module' => 'button', 'preset' => 'CTA primär', 'rename' => '<i></i>'],
]]), 'validation_failed');
self::assertStringContainsString('1–100 Zeichen', $empty['fields']['items[0].rename']);
}
public function testNothingIsWrittenWhenOneItemHasErrors(): void
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('presets')->andReturnUsing(fn (): array => $this->stored);
$writer->allows('canUseVisualBuilder')->andReturn(true);
$writer->shouldNotReceive('savePresets');
$this->snapshots->shouldNotReceive('save');
$ability = $this->ability(true, null, null, $writer);
$result = $ability->execute(['items' => [
['module' => 'button', 'name' => 'Gut'],
['module' => 'button', 'name' => 'Schlecht', 'attrs' => ['button.innerContent' => ['desktop' => ['text' => 'Hallo']]]],
], 'confirm' => true, 'design_rev' => 'egal']);
self::assertContains(Errors::codeOf($result), ['validation_failed', 'invalid_attr_path']);
self::assertNotEmpty(array_filter(
array_keys(Errors::fieldsOf($result)),
static fn (string $key): bool => str_starts_with($key, 'items[1]')
));
}
public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void
{
Functions\when('current_user_can')->justReturn(false);
self::assertToolError($this->ability()->permission(self::newButton()), 'forbidden');
}
public function testSchemaAndMeta(): void
{
$ability = $this->ability();
self::assertSame('lit/divi-presets-set', $ability->name());
self::assertFalse($ability->isDirect());
self::assertSame(DiviPresetsSetAbility::MAX_ITEMS, $ability->inputSchema()['properties']['items']['maxItems']);
self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2));
}
}
@@ -46,4 +46,17 @@ final class PresetDiffTest extends TestCase
'action' => 'default_cleared'], 'action' => 'default_cleared'],
], PresetDiff::between($before, $after)); ], PresetDiff::between($before, $after));
} }
public function testSummaryCountsTheActions(): void
{
self::assertSame(
'Presets: 1 angelegt, 1 geändert, 1 gelöscht, Standard 1× gesetzt, 1× entfernt.',
PresetDiff::summary([
['action' => 'added'], ['action' => 'changed'], ['action' => 'removed'],
['action' => 'default_set'], ['action' => 'default_cleared'],
])
);
self::assertSame('Presets: 2 angelegt.', PresetDiff::summary([['action' => 'added'], ['action' => 'added']]));
self::assertSame('Presets: keine Änderung.', PresetDiff::summary([]));
}
} }
@@ -32,6 +32,7 @@ final class DiviIntegrationTest extends TestCase
'lit/divi-node-copy', 'lit/divi-node-copy',
'lit/divi-dynamic-sources', 'lit/divi-dynamic-sources',
'lit/divi-preset-get', 'lit/divi-preset-get',
'lit/divi-presets-set',
]; ];
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void