Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php
T

330 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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));
}
}