Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
330 lines
15 KiB
PHP
330 lines
15 KiB
PHP
<?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\LockTable;
|
||
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);
|
||
// DesignLock (final review I-5): the lock is always free here.
|
||
LockTable::install();
|
||
}
|
||
|
||
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,
|
||
static fn (): bool => $unfiltered
|
||
);
|
||
}
|
||
|
||
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'));
|
||
}
|
||
|
||
/** R11: a default group preset no longer warns that pages are "not listed" – the dry run lists them. */
|
||
public function testDefaultGroupPresetHasNoNotListedWarning(): void
|
||
{
|
||
$dry = self::assertToolError($this->ability()->execute(['items' => [[
|
||
'group' => 'font-body', 'preset' => 'Lesetext', 'rename' => 'Lesetext neu', 'make_default' => true,
|
||
]]]), 'confirm_required');
|
||
|
||
self::assertSame([], $dry['warnings']);
|
||
}
|
||
|
||
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']);
|
||
}
|
||
|
||
/** Review Focus 1 (final review I-1): a catalog-known builder-only path, refused for editors only. */
|
||
public function testEditorWithoutUnfilteredHtmlIsRefusedForBuilderOnlyFields(): void
|
||
{
|
||
$input = ['items' => [[
|
||
'module' => 'button', 'name' => 'Mit HTML',
|
||
'attrs' => ['module.advanced.html' => ['desktop' => ['elementType' => 'div']]],
|
||
]]];
|
||
|
||
$editor = self::assertToolError($this->ability(false)->execute($input), 'validation_failed');
|
||
self::assertArrayHasKey('items[0].attrs.module.advanced.html', $editor['fields']);
|
||
self::assertStringContainsString('Divi-Builder', $editor['fields']['items[0].attrs.module.advanced.html']);
|
||
self::assertStringContainsString('unfiltered_html', $editor['fields']['items[0].attrs.module.advanced.html']);
|
||
|
||
self::assertToolError($this->ability(true)->execute($input), 'confirm_required');
|
||
}
|
||
|
||
/** Final review I-2: Divi would strip other presets' HTML/CSS when an editor saves – refused instead. */
|
||
public function testEditorIsRefusedWhenOtherPresetsCarryMarkup(): void
|
||
{
|
||
$this->stored = PresetBookTest::storedWithMarkup();
|
||
$input = ['items' => [[
|
||
'module' => 'button', 'preset' => 'CTA primär',
|
||
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#222222']]],
|
||
]]];
|
||
|
||
$editor = self::assertToolError($this->ability(false)->execute($input), 'forbidden');
|
||
self::assertStringContainsString('unfiltered_html', $editor['message']);
|
||
self::assertStringContainsString('nichts wurde gespeichert', $editor['message']);
|
||
self::assertStringContainsString('Administrator', $editor['hint']);
|
||
|
||
self::assertToolError($this->ability(true)->execute($input), 'confirm_required');
|
||
}
|
||
|
||
/** The changed preset's own HTML/CSS would be stripped as well (closest safe variant of I-2). */
|
||
public function testEditorIsRefusedWhenTheChangedPresetCarriesMarkup(): void
|
||
{
|
||
$this->stored = PresetBookTest::storedWithMarkup();
|
||
unset($this->stored['group']['divi/font-body']['items']['gggggggggg']['styleAttrs']);
|
||
|
||
$editor = self::assertToolError($this->ability(false)->execute(['items' => [[
|
||
'module' => 'button', 'preset' => 'bbbbbbbbbb',
|
||
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#222222']]],
|
||
]]]), 'forbidden');
|
||
self::assertStringContainsString('unfiltered_html', $editor['message']);
|
||
}
|
||
|
||
public function testEditorMayChangePresetsWhenNoneCarriesMarkup(): void
|
||
{
|
||
self::assertToolError($this->ability(false)->execute(['items' => [[
|
||
'module' => 'button', 'preset' => 'CTA primär',
|
||
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#222222']]],
|
||
]]]), 'confirm_required');
|
||
self::assertToolError($this->ability(false)->execute(self::newButton()), 'confirm_required');
|
||
}
|
||
|
||
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']);
|
||
}
|
||
|
||
/** Final review M-5: rename is cleaned by the ability and reaches the composer as the resolved name. */
|
||
public function testRenameIsCleanedAndApplied(): void
|
||
{
|
||
$ability = $this->ability();
|
||
$input = ['items' => [['module' => 'button', 'preset' => 'CTA primär', 'rename' => '<b>CTA</b> neu']]];
|
||
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
|
||
self::assertSame(['changed'], array_column($dry['diff'], 'action'));
|
||
|
||
$this->snapshots->expects('save')->andReturn('presets-1');
|
||
$this->snapshots->expects('attachLog');
|
||
self::assertTrue($ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']])['ok']);
|
||
$items = PresetBook::fromDivi($this->stored)->items(new PresetScope('module', 'divi/button'));
|
||
self::assertSame('CTA neu', $items['aaaaaaaaaa']['name']);
|
||
}
|
||
|
||
public function testNameAndRenameAreExclusive(): void
|
||
{
|
||
$ability = $this->ability();
|
||
$new = self::assertToolError($ability->execute(['items' => [
|
||
['module' => 'button', 'name' => 'Neu', 'rename' => '<b>x</b>'],
|
||
]]), 'validation_failed');
|
||
self::assertStringContainsString('nur zusammen mit preset', $new['fields']['items[0].rename']);
|
||
|
||
$change = self::assertToolError($ability->execute(['items' => [
|
||
['module' => 'button', 'preset' => 'CTA primär', 'name' => 'CTA primär'],
|
||
]]), 'validation_failed');
|
||
self::assertStringContainsString('rename', $change['fields']['items[0].name']);
|
||
}
|
||
|
||
public function testRenameToAnotherPresetsNameIsRefused(): void
|
||
{
|
||
$result = self::assertToolError($this->ability()->execute(['items' => [
|
||
['module' => 'button', 'preset' => 'CTA primär', 'rename' => 'Doppelt'],
|
||
]]), 'validation_failed');
|
||
|
||
self::assertStringContainsString('bbbbbbbbbb', $result['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',
|
||
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#333333']]]],
|
||
['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));
|
||
}
|
||
}
|