feat: lit/divi-variables-set legt globale Variablen an, ändert und archiviert sie (Probelauf, design_rev, Snapshot)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
f1f70114f4
commit
d7a8b85f8d
@@ -0,0 +1,373 @@
|
|||||||
|
<?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\Library\GlobalUsages;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||||
|
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lit/divi-variables-set (spec §7.2 with rulings V1, V6–V8): creates and changes global variables through the change
|
||||||
|
* pipeline (VariableArea). An item names an existing variable in id (ID or name) or creates one from type, label and
|
||||||
|
* value. Variables are never deleted – Divi refuses an inventory that lacks a stored ID (V1) –, only archived (order
|
||||||
|
* "") and activated again (appended at the end of their type); the type of a variable stays, gradients are left to the
|
||||||
|
* builder. Values are checked per type (VariableValue); an active variable needs one. The dry run lists the posts that
|
||||||
|
* reference a changed, archived or activated variable (ReferenceUsageQuery).
|
||||||
|
*/
|
||||||
|
final class DiviVariablesSetAbility implements Ability, RequiresDiviFeatures
|
||||||
|
{
|
||||||
|
public const MAX_ITEMS = 50;
|
||||||
|
|
||||||
|
private const LABEL_MAX = 100;
|
||||||
|
|
||||||
|
private const LABEL_RULE = 'Name: 1–100 Zeichen, ohne HTML';
|
||||||
|
|
||||||
|
private const STATUSES = ['active', 'archived'];
|
||||||
|
|
||||||
|
/** A variable as Divi stores it (formats §2); new variables start from it. */
|
||||||
|
private const NEW_ENTRY = ['id' => '', 'label' => '', 'value' => '', 'order' => '', 'status' => 'active',
|
||||||
|
'lastUpdated' => '', 'variableType' => ''];
|
||||||
|
|
||||||
|
/** @param (Closure(int): string)|null $random Random characters for new IDs (VariableBook::newId(); tests). */
|
||||||
|
public function __construct(
|
||||||
|
private DesignSystemWriter $writer,
|
||||||
|
private GlobalVariablesAdapter $variables,
|
||||||
|
private DesignChange $change,
|
||||||
|
private GlobalUsages $usages,
|
||||||
|
private ?Closure $random = null
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'lit/divi-variables-set';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return 'Globale Divi-Variablen anlegen und ändern';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Legt globale Variablen an oder ändert sie. items: id nennt eine vorhandene Variable (ID oder Name); '
|
||||||
|
. 'ohne id entsteht eine neue aus type (numbers, strings, images, links, fonts), label und value. label '
|
||||||
|
. 'benennt um, status archived archiviert (Variablen werden nie gelöscht), active holt sie zurück; der Typ '
|
||||||
|
. 'bleibt. Verweise vor dem Archivieren mit lit/divi-design-detach durch den Wert ersetzen. Wirkt auf alle '
|
||||||
|
. 'Seiten: zuerst Probelauf (diff, betroffene Seiten, Warnungen, 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' => [
|
||||||
|
'id' => ['type' => 'string', 'description' => 'Vorhandene Variable: ID oder Name.'],
|
||||||
|
'type' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => 'Neue Variable: numbers, strings, images, links oder fonts.',
|
||||||
|
],
|
||||||
|
'label' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => 'Name einer neuen Variable oder umbenennen.',
|
||||||
|
],
|
||||||
|
'value' => ['type' => 'string'],
|
||||||
|
'status' => ['type' => 'string', 'description' => 'active oder archived.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
] + 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads and writes the global variables; the warnings read the presets (DesignRead). */
|
||||||
|
public function diviFeatures(array $input): array
|
||||||
|
{
|
||||||
|
return [DiviFeature::DesignRead, DiviFeature::VariableWrite];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function permission(array $input): bool|WP_Error
|
||||||
|
{
|
||||||
|
return DesignRights::variables($this->writer, $this->variables) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(array $input): mixed
|
||||||
|
{
|
||||||
|
$refusal = DesignRights::variables($this->writer, $this->variables);
|
||||||
|
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 VariableArea($this->variables),
|
||||||
|
$input,
|
||||||
|
fn (array $raw): DesignPlan|WP_Error => $this->plan(VariableBook::fromDivi($raw), $items)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<mixed> $items */
|
||||||
|
private function plan(VariableBook $before, array $items): DesignPlan|WP_Error
|
||||||
|
{
|
||||||
|
$book = $before;
|
||||||
|
$errors = [];
|
||||||
|
foreach ($items as $index => $item) {
|
||||||
|
$field = sprintf('items[%d]', $index);
|
||||||
|
if (! is_array($item)) {
|
||||||
|
$errors[$field] = 'Objekt erwartet';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$book = $this->apply($book, $item, $field, $errors);
|
||||||
|
}
|
||||||
|
if ($errors !== []) {
|
||||||
|
return Errors::invalidInput('Ungültige Variablen-Angaben; nichts wurde gespeichert.', $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
$diff = VariableDiff::between($before, $book);
|
||||||
|
$touched = [];
|
||||||
|
foreach ($diff as $row) {
|
||||||
|
if ($row['action'] !== 'added') {
|
||||||
|
$touched[] = (string) $row['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$touched = array_values(array_unique($touched));
|
||||||
|
|
||||||
|
return new DesignPlan(
|
||||||
|
$book->data(),
|
||||||
|
$diff,
|
||||||
|
VariableDiff::summary($diff),
|
||||||
|
$touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* $book with the variable of $item created or changed; problems go to $errors and leave $book as it was.
|
||||||
|
*
|
||||||
|
* @param array<array-key, mixed> $item
|
||||||
|
* @param array<string, string> $errors
|
||||||
|
*/
|
||||||
|
private function apply(VariableBook $book, array $item, string $field, array &$errors): VariableBook
|
||||||
|
{
|
||||||
|
$known = count($errors);
|
||||||
|
$named = array_key_exists('id', $item);
|
||||||
|
$found = $named ? self::existing($book, $item, $field, $errors) : null;
|
||||||
|
$type = $found['type'] ?? ($named ? null : self::newType($item, $field, $errors));
|
||||||
|
if ($type === null || count($errors) > $known) {
|
||||||
|
return $book;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $found['id'] ?? null;
|
||||||
|
$before = $id === null ? null : $book->entry($type, $id);
|
||||||
|
$entry = $before ?? array_merge(self::NEW_ENTRY, ['variableType' => $type]);
|
||||||
|
foreach (['label', 'value', 'status'] as $key) {
|
||||||
|
if (! array_key_exists($key, $item)) {
|
||||||
|
if ($before === null && $key !== 'status') {
|
||||||
|
$errors[$field . '.' . $key] = 'Pflicht für eine neue Variable';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$entry[$key] = match ($key) {
|
||||||
|
'label' => self::cleanLabel($book, $item['label'], $id),
|
||||||
|
'value' => self::value($type, $item['value']),
|
||||||
|
default => self::status($item['status']),
|
||||||
|
};
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
$errors[$field . '.' . $key] = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($errors) > $known) {
|
||||||
|
return $book;
|
||||||
|
}
|
||||||
|
if ($entry['status'] === 'active' && ! self::hasValue($entry['value'] ?? null)) {
|
||||||
|
// Divi refuses it too (global_variable_active_value_empty, ruling V6).
|
||||||
|
$errors[$field . '.value'] = 'aktive Variablen brauchen einen Wert';
|
||||||
|
|
||||||
|
return $book;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry['order'] = match (true) {
|
||||||
|
$entry['status'] !== 'active' => '',
|
||||||
|
($before['status'] ?? null) !== 'active' => $book->nextOrder($type),
|
||||||
|
default => $entry['order'],
|
||||||
|
};
|
||||||
|
if ($entry === $before) {
|
||||||
|
return $book;
|
||||||
|
}
|
||||||
|
$id ??= $this->newId($book);
|
||||||
|
$entry['id'] = $id;
|
||||||
|
$entry['lastUpdated'] = VariableBook::now();
|
||||||
|
|
||||||
|
return $book->with($type, $id, $entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The variable $item['id'] names (within $item['type'] first, when given), or null after noting the problem.
|
||||||
|
*
|
||||||
|
* @param array<array-key, mixed> $item
|
||||||
|
* @param array<string, string> $errors
|
||||||
|
* @return array{type: string, id: string}|null
|
||||||
|
*/
|
||||||
|
private static function existing(VariableBook $book, array $item, string $field, array &$errors): ?array
|
||||||
|
{
|
||||||
|
$needle = $item['id'];
|
||||||
|
if (! is_string($needle) || trim($needle) === '') {
|
||||||
|
$errors[$field . '.id'] = 'ID oder Name der Variablen erwartet';
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$type = is_string($item['type'] ?? null) ? $item['type'] : null;
|
||||||
|
try {
|
||||||
|
$found = ($type === null ? null : $book->find($needle, $type)) ?? $book->find($needle);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
$errors[$field . '.id'] = $e->getMessage();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($found === null) {
|
||||||
|
$errors[$field . '.id'] = sprintf('unbekannte Variable „%s“', $needle);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (! in_array($found['type'], VariableValue::TYPES, true)) {
|
||||||
|
$errors[$field . '.id'] = sprintf('Variablen vom Typ %s nur im Divi-Builder ändern', $found['type']);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($type !== null && $type !== $found['type']) {
|
||||||
|
$errors[$field . '.type'] = sprintf('Der Typ einer Variablen bleibt (ist %s)', $found['type']);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array-key, mixed> $item
|
||||||
|
* @param array<string, string> $errors
|
||||||
|
*/
|
||||||
|
private static function newType(array $item, string $field, array &$errors): ?string
|
||||||
|
{
|
||||||
|
$type = $item['type'] ?? null;
|
||||||
|
if (is_string($type) && in_array($type, VariableValue::TYPES, true)) {
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
$errors[$field . '.type'] = sprintf(
|
||||||
|
'Pflicht für eine neue Variable, einer von: %s',
|
||||||
|
implode(', ', VariableValue::TYPES)
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A label no other variable has as name or ID (it would make {{var:…}} ambiguous).
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
private static function cleanLabel(VariableBook $book, mixed $label, ?string $id): string
|
||||||
|
{
|
||||||
|
$clean = is_string($label) && strlen($label) <= self::LABEL_MAX * 4 ? sanitize_text_field($label) : '';
|
||||||
|
// Divi drops variables whose label is empty or "0" (falsy) without a message.
|
||||||
|
if ($clean === '' || $clean === '0' || mb_strlen($clean) > self::LABEL_MAX) {
|
||||||
|
throw new InvalidArgumentException(self::LABEL_RULE);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$taken = $book->find($clean)['id'] ?? null;
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
$taken = ''; // several variables share the name already
|
||||||
|
}
|
||||||
|
if ($taken !== null && $taken !== $id) {
|
||||||
|
throw new InvalidArgumentException(sprintf(
|
||||||
|
'Name schon vergeben (%s); zum Ändern id angeben oder einen anderen Namen wählen',
|
||||||
|
$taken === '' ? 'mehrfach' : $taken
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws InvalidArgumentException */
|
||||||
|
private static function value(string $type, mixed $value): string
|
||||||
|
{
|
||||||
|
if (is_int($value)) {
|
||||||
|
$value = (string) $value; // an attachment ID for images
|
||||||
|
}
|
||||||
|
if (! is_string($value)) {
|
||||||
|
throw new InvalidArgumentException('Wert als Text erwartet');
|
||||||
|
}
|
||||||
|
$problem = VariableValue::check($type, $value);
|
||||||
|
if ($problem !== null) {
|
||||||
|
throw new InvalidArgumentException($problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws InvalidArgumentException */
|
||||||
|
private static function status(mixed $status): string
|
||||||
|
{
|
||||||
|
if (! is_string($status) || ! in_array($status, self::STATUSES, true)) {
|
||||||
|
throw new InvalidArgumentException('active oder archived');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasValue(mixed $value): bool
|
||||||
|
{
|
||||||
|
return is_scalar($value) && (string) $value !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function newId(VariableBook $book): string
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$id = VariableBook::newId($this->random);
|
||||||
|
} while ($book->typeOf($id) !== null);
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
<?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\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\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.
|
||||||
|
Functions\when('add_option')->justReturn(true);
|
||||||
|
Functions\when('delete_option')->justReturn(true);
|
||||||
|
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
|
||||||
|
): DiviVariablesSetAbility {
|
||||||
|
$writer = Mockery::mock(DesignSystemWriter::class);
|
||||||
|
$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()),
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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('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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user