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

438 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DiviVariablesSetAbility;
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Design\VariableBook;
use LIT\MCP\Integrations\Divi\Design\VariableDiff;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\LockTable;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
final class DiviVariablesSetAbilityTest extends TestCase
{
/** @var array<string, array<string, array<string, mixed>>> What VariableAreaTest::adapter() stores. */
private array $stored = [];
/** @var list<array{0: array<string, mixed>, 1: array<string, mixed>}> */
private array $saves = [];
protected function setUp(): void
{
parent::setUp();
// DesignLock (final review I-5): the lock is always free here.
LockTable::install();
Functions\when('current_user_can')->justReturn(true);
$this->stored = VariableAreaTest::stored();
$this->saves = [];
}
/** @param array<string, mixed> $presets DesignSystemWriter::presets() ([] = PresetBookTest::stored()). */
private function ability(
?GlobalUsages $usages = null,
?SnapshotStore $snapshots = null,
array $presets = [],
?GlobalVariablesAdapter $variables = null,
bool $manage = true,
?FontCatalog $fonts = null
): DiviVariablesSetAbility {
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('refresh');
$writer->allows('canUseVisualBuilder')->andReturn(true);
$writer->allows('presets')->andReturn($presets === [] ? PresetBookTest::stored() : $presets);
$counter = 0;
return new DiviVariablesSetAbility(
$writer,
$variables ?? VariableAreaTest::adapter($this->stored, $this->saves, $manage),
new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)),
$usages ?? self::usages(self::unused()),
$fonts ?? FontCheckTest::catalog(),
static function (int $length) use (&$counter): string {
return str_pad((string) ++$counter, $length, '0', STR_PAD_LEFT);
}
);
}
private static function usages(UsageReport $report): GlobalUsages
{
$usages = Mockery::mock(GlobalUsages::class);
$usages->allows('reportFor')->andReturn($report);
return $usages;
}
private static function unused(): UsageReport
{
return new UsageReport(0, [], 0, 0, [], 0, false);
}
private static function used(): UsageReport
{
return new UsageReport(1, [['post_id' => 7, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']],
0, 1, [], 0, false);
}
/** A SnapshotStore that expects one snapshot of the stored variables and the log ID 3 attached to it. */
private static function snapshots(): SnapshotStore
{
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')
->with('variables', VariableBook::fromDivi(VariableAreaTest::stored())->data(), 'lit/divi-variables-set')
->andReturn('variables-1');
$snapshots->expects('attachLog')->with('variables', 'variables-1', 3);
return $snapshots;
}
public function testSchemaAndMeta(): void
{
$ability = $this->ability();
$properties = $ability->inputSchema()['properties'];
self::assertSame('lit/divi-variables-set', $ability->name());
self::assertFalse($ability->isDirect());
self::assertSame(Annotations::destructive(false), $ability->annotations());
self::assertSame([DiviFeature::DesignRead, DiviFeature::VariableWrite], $ability->diviFeatures([]));
self::assertSame(50, DiviVariablesSetAbility::MAX_ITEMS);
self::assertSame(DiviVariablesSetAbility::MAX_ITEMS, $properties['items']['maxItems']);
self::assertArrayNotHasKey('delete', $properties['items']['items']['properties'], 'ruling V1: only archived');
self::assertArrayHasKey('design_rev', $properties);
self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2));
self::assertStringContainsString('confirm: true mit design_rev', $ability->description());
self::assertStringContainsString('lit/divi-design-restore', $ability->description());
self::assertStringContainsString('lit/divi-design-detach', $ability->description());
}
public function testNeedsTheVariablesManagerAndEditThemeOptions(): void
{
$input = ['items' => [['type' => 'numbers', 'label' => 'Abstand', 'value' => '24px']]];
self::assertToolError($this->ability(manage: false)->execute($input), 'forbidden');
self::assertToolError($this->ability(manage: false)->permission($input), 'forbidden');
Functions\when('current_user_can')->justReturn(false);
self::assertToolError($this->ability()->execute($input), 'forbidden');
self::assertSame([], $this->saves);
}
public function testItemsMustBeAListOfOneToFifty(): void
{
$tooMany = array_fill(0, 51, ['type' => 'numbers', 'label' => 'X', 'value' => '1px']);
foreach ([[], ['items' => []], ['items' => ['a' => []]], ['items' => $tooMany]] as $input) {
self::assertToolError($this->ability()->execute($input), 'validation_failed');
}
}
public function testDryRunThenConfirmCreatesAVariable(): void
{
$usages = Mockery::mock(GlobalUsages::class);
$usages->expects('reportFor')->never(); // ruling V8: a new variable has no usages
$ability = $this->ability($usages, self::snapshots());
$input = ['items' => [['type' => 'numbers', 'label' => 'Abstand', 'value' => '24px']]];
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
self::assertSame(['added'], array_column($dry['diff'], 'action'));
self::assertSame('Abstand', $dry['diff'][0]['name']);
self::assertSame(VariableBook::fromDivi(VariableAreaTest::stored())->rev(), $dry['design_rev']);
self::assertSame([], $dry['affected']);
self::assertSame([], $dry['warnings']);
self::assertStringContainsString(VariableDiff::summary($dry['diff']), $dry['message']);
self::assertSame([], $this->saves, 'the dry run saves nothing');
$result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
self::assertSame('variables-1', $result['snapshot_id']);
$created = array_diff_key($this->stored['numbers'], VariableAreaTest::stored()['numbers']);
self::assertCount(1, $created);
$id = (string) array_key_first($created);
self::assertMatchesRegularExpression('/^gvid-[0-9a-z]{10}$/D', $id);
self::assertSame(
[$id, 'Abstand', '24px', '2', 'active', 'numbers'],
[$created[$id]['id'], $created[$id]['label'], $created[$id]['value'], $created[$id]['order'],
$created[$id]['status'], $created[$id]['variableType']]
);
self::assertMatchesRegularExpression(
'/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/D',
$created[$id]['lastUpdated']
);
self::assertSame(VariableAreaTest::stored(), $this->saves[0][1], 'Divi checks against the inventory read');
self::assertSame(VariableBook::fromDivi($this->stored)->rev(), $result['design_rev']);
}
public function testConfirmNeedsTheDesignRevOfTheDryRun(): void
{
$input = ['items' => [['id' => 'Eckenradius', 'value' => '12px']], 'confirm' => true];
self::assertToolError($this->ability()->execute($input), 'validation_failed');
self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch');
self::assertSame([], $this->saves);
}
public function testChangingValueAndLabelListsTheUsages(): void
{
$usages = Mockery::mock(GlobalUsages::class);
$usages->expects('reportFor')
->twice() // dry run and confirm
->with(Mockery::on(static fn (ReferenceUsageQuery $query): bool
=> $query->matches('<!-- wp:divi/text {"x":"gvid-slogan0001"} /-->')
&& ! $query->matches('<!-- wp:divi/text {"x":"gvid-radius0001"} /-->')))
->andReturn(self::used());
$ability = $this->ability($usages, self::snapshots());
$input = ['items' => [['id' => 'slogan', 'label' => 'Leitsatz', 'value' => 'Holz mit Seele']]];
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
self::assertSame(['changed'], array_column($dry['diff'], 'action'));
self::assertSame(7, $dry['affected'][0]['post_id']);
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
$slogan = $this->stored['strings']['gvid-slogan0001'];
self::assertSame(['Leitsatz', 'Holz mit Seele', '1', 'active'], [$slogan['label'], $slogan['value'],
$slogan['order'], $slogan['status']]);
}
public function testTheTypeOfAVariableStays(): void
{
$error = $this->ability()->execute(['items' => [['id' => 'Eckenradius', 'type' => 'strings', 'value' => 'x']]]);
self::assertToolError($error, 'validation_failed');
self::assertSame(['items[0].type'], array_keys(self::errorFields($error)));
}
/** Ruling V6: gradients are passed through, never changed by the tool. */
public function testGradientsAreLeftToTheBuilder(): void
{
$error = $this->ability()->execute(['items' => [['id' => 'Verlauf', 'status' => 'archived']]]);
self::assertToolError($error, 'validation_failed');
self::assertSame(['items[0].id'], array_keys(self::errorFields($error)));
}
/** Ruling V1: archiving clears the order; activating appends the variable at the end of its type. */
public function testArchivingClearsTheOrderAndActivatingAppendsIt(): void
{
$ability = $this->ability(null, self::snapshots());
$input = ['items' => [
['id' => 'Eckenradius', 'status' => 'archived'],
['id' => 'Alter Slogan', 'status' => 'active'],
]];
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
$actions = array_column($dry['diff'], 'action');
sort($actions);
self::assertSame(['activated', 'archived'], $actions);
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
$radius = $this->stored['numbers']['gvid-radius0001'];
$old = $this->stored['strings']['gvid-alt0000001'];
self::assertSame(['archived', ''], [$radius['status'], $radius['order']]);
self::assertSame(['active', '2'], [$old['status'], $old['order']]);
self::assertSame('8px', $radius['value'], 'archiving keeps the value');
}
public function testActiveVariablesNeedAValue(): void
{
$error = $this->ability()->execute(['items' => [['id' => 'gvid-leer000001', 'status' => 'active']]]);
self::assertToolError($error, 'validation_failed');
self::assertSame(['items[0].value'], array_keys(self::errorFields($error)));
}
public function testAllProblemsOfAllItemsAreCollected(): void
{
$error = $this->ability()->execute(['items' => [
['id' => 'Fehlt', 'value' => '1px'],
['type' => 'colors', 'label' => 'Farbe', 'value' => '#fff'],
['type' => 'numbers', 'label' => 'eckenradius', 'value' => '1px'],
['type' => 'numbers', 'label' => 'Neu', 'value' => '1px;}'],
['type' => 'numbers', 'label' => 'Ohne Wert'],
['id' => 'Slogan', 'status' => 'gelöscht'],
'kein Objekt',
]]);
self::assertToolError($error, 'validation_failed');
self::assertSame(
['items[0].id', 'items[1].type', 'items[2].label', 'items[3].value', 'items[4].value', 'items[5].status',
'items[6]'],
array_keys(self::errorFields($error))
);
self::assertSame([], $this->saves);
}
/** Ruling V1: "delete" (or a typo) must not look like a successful no-op. */
public function testUnknownItemKeysAreRefusedAndCollectedWithOtherErrors(): void
{
$error = $this->ability()->execute(['items' => [
['id' => 'Slogan', 'delete' => true],
['id' => 'Slogan', 'stauts' => 'archived'],
['id' => 'Fehlt', 'value' => '1px'],
]]);
self::assertToolError($error, 'validation_failed');
$fields = self::errorFields($error);
self::assertSame(['items[0].delete', 'items[1].stauts', 'items[2].id'], array_keys($fields));
self::assertStringContainsString('archiviert', $fields['items[0].delete']);
self::assertSame('unbekanntes Feld', $fields['items[1].stauts']);
self::assertSame([], $this->saves);
self::assertFalse($this->ability()->inputSchema()['properties']['items']['items']['additionalProperties']);
}
/** Divi silently drops variables with an empty label or the label "0" (sanitize_global_variables_data). */
public function testEmptyAndZeroLabelsAreRefused(): void
{
$error = $this->ability()->execute(['items' => [
['type' => 'numbers', 'label' => '', 'value' => '1px'],
['type' => 'numbers', 'label' => '0', 'value' => '1px'],
['id' => 'Eckenradius', 'label' => ' '],
]]);
self::assertToolError($error, 'validation_failed');
self::assertSame(['items[0].label', 'items[1].label', 'items[2].label'], array_keys(self::errorFields($error)));
}
public function testAFailedWriteIsRolledBack(): void
{
$calls = [];
$variables = Mockery::mock(GlobalVariablesAdapter::class);
$variables->allows('refresh');
$variables->allows('canManage')->andReturn(true);
$variables->allows('read')->andReturn(VariableAreaTest::stored());
$variables->allows('save')->andReturnUsing(static function (array $data, array $expected) use (&$calls): void {
$calls[] = $data;
if (count($calls) === 1) {
throw new RuntimeException('global_variables_stale');
}
});
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('variables-1');
$ability = $this->ability(null, $snapshots, [], $variables);
$input = ['items' => [['id' => 'Eckenradius', 'value' => '12px']]];
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
try {
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
self::fail('expected WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertSame('variables-1', $e->details['snapshot_id']);
}
self::assertCount(2, $calls);
self::assertSame(VariableBook::fromDivi(VariableAreaTest::stored())->data(), $calls[1], 'the snapshot again');
}
/** Ruling V1a/V8: archiving a used variable is allowed; presets and posts are warnings, the dry run lists posts. */
public function testArchivingAUsedVariableWarnsAndListsTheUsages(): void
{
$dry = self::assertToolError(
$this->ability(self::usages(self::used()), null, PresetBookTest::storedWithReferences())
->execute(['items' => [['id' => 'Slogan', 'status' => 'archived']]]),
'confirm_required'
);
self::assertSame(7, $dry['affected'][0]['post_id']);
self::assertSame([
'Variable „Slogan“ wird archiviert, aber noch von Presets verwendet: Doppelt.',
'Die unter affected genannten Inhalte verweisen auf geänderte oder archivierte Variablen; archivierte '
. 'Variablen bleiben dort als Verweis stehen. lit/divi-design-detach mit variable ersetzt die Verweise '
. 'vorher durch den Wert.',
], $dry['warnings']);
}
public function testChangingAVariableWarnsAboutThePresetsThatUseIt(): void
{
$dry = self::assertToolError(
$this->ability(null, null, PresetBookTest::storedWithReferences())
->execute(['items' => [['id' => 'Eckenradius', 'value' => '12px']]]),
'confirm_required'
);
self::assertSame(
['Die Änderung an Variable „Eckenradius“ wirkt auch auf die Presets Lesetext.'],
$dry['warnings']
);
}
public function testNewVariablesHaveNoWarnings(): void
{
$dry = self::assertToolError(
$this->ability(null, null, PresetBookTest::storedWithReferences())
->execute(['items' => [['type' => 'strings', 'label' => 'Gruß', 'value' => 'Hallo']]]),
'confirm_required'
);
self::assertSame([], $dry['warnings']);
}
/** Ruling W12: a font variable names a font Divi knows exactly; unknown names get suggestions. */
public function testFontVariablesNeedAFontDiviKnows(): void
{
$error = $this->ability()
->execute(['items' => [['type' => 'fonts', 'label' => 'Marke', 'value' => 'robotto']]]);
self::assertToolError($error, 'validation_failed');
$fields = self::errorFields($error);
self::assertSame(['items[0].value'], array_keys($fields));
self::assertStringStartsWith('unbekannte Schrift „robotto“; ähnlich: ', $fields['items[0].value']);
self::assertStringContainsString('Roboto', $fields['items[0].value']);
self::assertSame([], $this->saves);
}
public function testAFontVariableWithAKnownFontIsPlanned(): void
{
$dry = self::assertToolError(
$this->ability()->execute(['items' => [['type' => 'fonts', 'label' => 'Marke', 'value' => 'Roboto Slab']]]),
'confirm_required'
);
self::assertSame(['added'], array_column($dry['diff'], 'action'));
}
/** The font list is loaded only for font variables (it costs 1.3 MB, ruling W5). */
public function testOtherTypesDoNotLoadTheFontList(): void
{
$fonts = Mockery::mock(FontCatalog::class);
$fonts->shouldNotReceive('has', 'names');
self::assertToolError(
$this->ability(fonts: $fonts)->execute(['items' => [
['type' => 'strings', 'label' => 'Gruß', 'value' => 'Hallo'],
['id' => 'Eckenradius', 'value' => '12px'],
]]),
'confirm_required'
);
}
/** VariableValue checks first: a malformed name never reaches the font list. */
public function testAMalformedFontNameFailsBeforeTheFontList(): void
{
$fonts = Mockery::mock(FontCatalog::class);
$fonts->shouldNotReceive('has', 'names');
$error = $this->ability(fonts: $fonts)
->execute(['items' => [['type' => 'fonts', 'label' => 'Marke', 'value' => 'Roboto; x']]]);
self::assertToolError($error, 'validation_failed');
self::assertStringStartsWith('Schrift:', self::errorFields($error)['items[0].value']);
}
}