feat: lit/divi-colors-set legt globale Farben an, ändert und deaktiviert 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
c1f77d2017
commit
337b87b572
@@ -0,0 +1,342 @@
|
||||
<?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-colors-set (spec §7.1, rulings V3–V5, V8): creates and changes global colors through the change pipeline
|
||||
* (ColorArea). An item names an existing color in id (ID, label, alias primary … or option name accent_color …) or
|
||||
* creates one from label and color. Colors are never deleted, only deactivated; the five main colors only change
|
||||
* their value. The dry run lists the posts that reference a changed, deactivated or activated color
|
||||
* (ReferenceUsageQuery).
|
||||
*/
|
||||
final class DiviColorsSetAbility 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', 'inactive'];
|
||||
|
||||
private const MAIN_COLOR_RULE = 'Hauptfarben ändern nur color, nicht label, folder oder status';
|
||||
|
||||
/** A color as Divi stores it (formats §1); new colors start from it. */
|
||||
private const NEW_ENTRY = ['color' => '', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active',
|
||||
'usedInPosts' => []];
|
||||
|
||||
/** @param (Closure(int): string)|null $random Random characters for new IDs (ColorBook::newId(); tests). */
|
||||
public function __construct(
|
||||
private DesignSystemWriter $writer,
|
||||
private GlobalColorsAdapter $colors,
|
||||
private DesignChange $change,
|
||||
private GlobalUsages $usages,
|
||||
private ?Closure $random = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/divi-colors-set';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Globale Divi-Farben anlegen und ändern';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Legt globale Farben an oder ändert sie. items: id nennt eine vorhandene Farbe (ID, Name, Alias '
|
||||
. 'primary, secondary, heading, body, link oder Optionsname wie accent_color); ohne id entsteht eine neue '
|
||||
. 'Farbe aus '
|
||||
. 'label und color. color: Hex, rgb()/rgba()/hsl()/hsla(), Farbname oder {{color:<Name>}} mit relative '
|
||||
. '(hue, saturation, lightness, opacity). status inactive deaktiviert (Farben werden nie gelöscht); die '
|
||||
. 'fünf Hauptfarben ändern nur color. 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
|
||||
{
|
||||
$number = ['type' => 'number'];
|
||||
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'items' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
'maxItems' => self::MAX_ITEMS,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => [
|
||||
'type' => 'string',
|
||||
'description' => 'Vorhandene Farbe: ID, Name, Alias oder Optionsname.',
|
||||
],
|
||||
'label' => ['type' => 'string', 'description' => 'Name einer neuen Farbe oder umbenennen.'],
|
||||
'color' => ['type' => 'string'],
|
||||
'relative' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'hue' => $number,
|
||||
'saturation' => $number,
|
||||
'lightness' => $number,
|
||||
'opacity' => $number,
|
||||
],
|
||||
],
|
||||
'folder' => ['type' => 'string'],
|
||||
'status' => ['type' => 'string', 'description' => 'active oder inactive.'],
|
||||
],
|
||||
],
|
||||
],
|
||||
] + 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 colors; the warnings read the presets (DesignRead). */
|
||||
public function diviFeatures(array $input): array
|
||||
{
|
||||
return [DiviFeature::DesignRead, DiviFeature::ColorWrite];
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
return DesignRights::colors($this->writer) ?? true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$refusal = DesignRights::colors($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 ColorArea($this->colors),
|
||||
$input,
|
||||
fn (array $raw): DesignPlan|WP_Error => $this->plan(ColorBook::fromDivi($raw), $items)
|
||||
);
|
||||
}
|
||||
|
||||
/** @param list<mixed> $items */
|
||||
private function plan(ColorBook $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 Farb-Angaben; nichts wurde gespeichert.', $errors);
|
||||
}
|
||||
|
||||
$diff = ColorDiff::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,
|
||||
ColorDiff::summary($diff),
|
||||
$touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* $book with the color 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(ColorBook $book, array $item, string $field, array &$errors): ColorBook
|
||||
{
|
||||
$known = count($errors);
|
||||
$id = array_key_exists('id', $item) ? self::existing($book, $item['id'], $field . '.id', $errors) : null;
|
||||
if (count($errors) > $known) {
|
||||
return $book;
|
||||
}
|
||||
|
||||
$main = $id !== null && $book->isCustomizer($id);
|
||||
$entry = $id === null ? self::NEW_ENTRY : (array) $book->entry($id);
|
||||
foreach (['label', 'folder', 'status', 'color'] as $key) {
|
||||
if (! array_key_exists($key, $item)) {
|
||||
if ($id === null && ($key === 'label' || $key === 'color')) {
|
||||
$errors[$field . '.' . $key] = 'Pflicht für eine neue Farbe';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($main && $key !== 'color') {
|
||||
$errors[$field . '.' . $key] = self::MAIN_COLOR_RULE;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$entry[$key] = match ($key) {
|
||||
'label' => self::cleanLabel($book, $item['label'], $id),
|
||||
'folder' => self::folder($item['folder']),
|
||||
'status' => self::status($item['status']),
|
||||
default => self::value($item),
|
||||
};
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$errors[$field . '.' . $key] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
if (count($errors) > $known || ($id !== null && $entry === $book->entry($id))) {
|
||||
return $book;
|
||||
}
|
||||
|
||||
if (! $main) {
|
||||
// Ruling V3: the main colors' lastUpdated stays "" (ColorBook ignores it).
|
||||
$entry['lastUpdated'] = ColorBook::now();
|
||||
}
|
||||
|
||||
return $book->with($id ?? $this->newId($book), $entry);
|
||||
}
|
||||
|
||||
/** @param array<string, string> $errors */
|
||||
private static function existing(ColorBook $book, mixed $needle, string $field, array &$errors): ?string
|
||||
{
|
||||
if (! is_string($needle) || trim($needle) === '') {
|
||||
$errors[$field] = 'ID, Name, Alias oder Optionsname der Farbe erwartet';
|
||||
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$id = $book->find($needle);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$errors[$field] = $e->getMessage();
|
||||
|
||||
return null;
|
||||
}
|
||||
if ($id === null) {
|
||||
$errors[$field] = sprintf('unbekannte Farbe „%s“', $needle);
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A label no other color has as name, ID or alias (it would make {{color:…}} ambiguous).
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private static function cleanLabel(ColorBook $book, mixed $label, ?string $id): string
|
||||
{
|
||||
$clean = is_string($label) && strlen($label) <= self::LABEL_MAX * 4 ? sanitize_text_field($label) : '';
|
||||
if ($clean === '' || mb_strlen($clean) > self::LABEL_MAX) {
|
||||
throw new InvalidArgumentException(self::LABEL_RULE);
|
||||
}
|
||||
try {
|
||||
$taken = $book->find($clean);
|
||||
} catch (InvalidArgumentException) {
|
||||
$taken = ''; // several colors 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 folder(mixed $folder): string
|
||||
{
|
||||
$clean = is_string($folder) && strlen($folder) <= self::LABEL_MAX * 4 ? sanitize_text_field($folder) : null;
|
||||
if ($clean === null || mb_strlen($clean) > self::LABEL_MAX || $clean === 'customizer') {
|
||||
throw new InvalidArgumentException('Ordner: bis 100 Zeichen ohne HTML, nicht „customizer“');
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private static function status(mixed $status): string
|
||||
{
|
||||
if (! is_string($status) || ! in_array($status, self::STATUSES, true)) {
|
||||
throw new InvalidArgumentException('active oder inactive');
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $item
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private static function value(array $item): string
|
||||
{
|
||||
$color = $item['color'];
|
||||
if (! is_string($color)) {
|
||||
throw new InvalidArgumentException('Farbwert als Text erwartet');
|
||||
}
|
||||
$color = trim($color);
|
||||
$problem = ColorValue::check($color);
|
||||
if ($problem !== null) {
|
||||
throw new InvalidArgumentException($problem);
|
||||
}
|
||||
|
||||
return $color;
|
||||
}
|
||||
|
||||
private function newId(ColorBook $book): string
|
||||
{
|
||||
do {
|
||||
$id = ColorBook::newId($this->random);
|
||||
} while ($book->entry($id) !== null);
|
||||
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?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\ColorBook;
|
||||
use LIT\MCP\Integrations\Divi\Design\ColorDiff;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility;
|
||||
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
|
||||
use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery;
|
||||
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\Release\DiviFeature;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use RuntimeException;
|
||||
|
||||
final class DiviColorsSetAbilityTest extends TestCase
|
||||
{
|
||||
/** @var array<string, array<string, mixed>> What the adapter stores (ColorAreaTest::adapter()). */
|
||||
private array $stored = [];
|
||||
|
||||
/** @var list<array{0: array<string, mixed>, 1: bool}> */
|
||||
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 = ColorAreaTest::stored();
|
||||
$this->saves = [];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $presets DesignSystemWriter::presets() ([] = PresetBookTest::stored()). */
|
||||
private function ability(
|
||||
?GlobalUsages $usages = null,
|
||||
?SnapshotStore $snapshots = null,
|
||||
array $presets = [],
|
||||
?GlobalColorsAdapter $colors = null,
|
||||
bool $visualBuilder = true
|
||||
): DiviColorsSetAbility {
|
||||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||||
$writer->allows('canUseVisualBuilder')->andReturn($visualBuilder);
|
||||
$writer->allows('presets')->andReturn($presets === [] ? PresetBookTest::stored() : $presets);
|
||||
$counter = 0;
|
||||
|
||||
return new DiviColorsSetAbility(
|
||||
$writer,
|
||||
$colors ?? ColorAreaTest::adapter($this->stored, $this->saves),
|
||||
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 colors and the log ID 3 attached to it. */
|
||||
private static function snapshots(): SnapshotStore
|
||||
{
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->expects('save')
|
||||
->with('colors', ColorBook::fromDivi(ColorAreaTest::stored())->data(), 'lit/divi-colors-set')
|
||||
->andReturn('colors-1');
|
||||
$snapshots->expects('attachLog')->with('colors', 'colors-1', 3);
|
||||
|
||||
return $snapshots;
|
||||
}
|
||||
|
||||
public function testSchemaAndMeta(): void
|
||||
{
|
||||
$ability = $this->ability();
|
||||
$properties = $ability->inputSchema()['properties'];
|
||||
|
||||
self::assertSame('lit/divi-colors-set', $ability->name());
|
||||
self::assertFalse($ability->isDirect());
|
||||
self::assertSame(Annotations::destructive(false), $ability->annotations());
|
||||
self::assertSame([DiviFeature::DesignRead, DiviFeature::ColorWrite], $ability->diviFeatures([]));
|
||||
self::assertSame(DiviColorsSetAbility::MAX_ITEMS, $properties['items']['maxItems']);
|
||||
self::assertSame(50, DiviColorsSetAbility::MAX_ITEMS);
|
||||
self::assertArrayHasKey('design_rev', $properties);
|
||||
self::assertArrayNotHasKey('delete', $properties['items']['items']['properties'], 'colors are deactivated');
|
||||
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());
|
||||
}
|
||||
|
||||
public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void
|
||||
{
|
||||
$input = ['items' => [['label' => 'Weiß', 'color' => '#ffffff']]];
|
||||
|
||||
self::assertToolError($this->ability(visualBuilder: false)->execute($input), 'forbidden');
|
||||
self::assertToolError($this->ability(visualBuilder: false)->permission($input), 'forbidden');
|
||||
|
||||
Functions\when('current_user_can')->justReturn(false);
|
||||
self::assertToolError($this->ability()->execute($input), 'forbidden');
|
||||
self::assertToolError($this->ability()->permission($input), 'forbidden');
|
||||
self::assertSame([], $this->saves);
|
||||
}
|
||||
|
||||
public function testItemsMustBeAListOfOneToFifty(): void
|
||||
{
|
||||
$tooMany = array_fill(0, 51, ['label' => 'X', 'color' => '#fff']);
|
||||
foreach ([[], ['items' => []], ['items' => ['a' => []]], ['items' => $tooMany]] as $input) {
|
||||
self::assertToolError($this->ability()->execute($input), 'validation_failed');
|
||||
}
|
||||
}
|
||||
|
||||
public function testDryRunThenConfirmCreatesAColor(): void
|
||||
{
|
||||
$usages = Mockery::mock(GlobalUsages::class);
|
||||
$usages->expects('reportFor')->never(); // ruling V8: a new color has no usages
|
||||
$ability = $this->ability($usages, self::snapshots());
|
||||
$input = ['items' => [['label' => 'Weiß', 'color' => '#ffffff', 'folder' => 'Basis']]];
|
||||
|
||||
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
|
||||
|
||||
self::assertSame(['added'], array_column($dry['diff'], 'action'));
|
||||
self::assertSame('Weiß', $dry['diff'][0]['name']);
|
||||
self::assertSame(ColorBook::fromDivi(ColorAreaTest::stored())->rev(), $dry['design_rev']);
|
||||
self::assertSame([], $dry['affected']);
|
||||
self::assertSame(0, $dry['affected_count']);
|
||||
self::assertSame([], $dry['warnings']);
|
||||
self::assertStringContainsString(ColorDiff::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('colors-1', $result['snapshot_id']);
|
||||
$created = array_diff_key($this->stored, ColorAreaTest::stored());
|
||||
self::assertCount(1, $created);
|
||||
self::assertMatchesRegularExpression('/^gcid-[0-9a-z]{10}$/D', (string) array_key_first($created));
|
||||
$entry = reset($created);
|
||||
self::assertSame(
|
||||
['#ffffff', 'Basis', 'Weiß', 'active', []],
|
||||
[$entry['color'], $entry['folder'], $entry['label'], $entry['status'], $entry['usedInPosts']]
|
||||
);
|
||||
self::assertMatchesRegularExpression('/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/D', $entry['lastUpdated']);
|
||||
self::assertFalse($this->saves[0][1], 'merged');
|
||||
self::assertSame(ColorBook::fromDivi($this->stored)->rev(), $result['design_rev']);
|
||||
}
|
||||
|
||||
public function testConfirmNeedsTheDesignRevOfTheDryRun(): void
|
||||
{
|
||||
$input = ['items' => [['id' => 'Holz dunkel', 'color' => '#000000']], '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 testChangingAColorWritesOnlyThatColorAndListsItsUsages(): 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":"gcid-holz000001"} /-->')
|
||||
&& ! $query->matches('<!-- wp:divi/text {"x":"gcid-sand000002"} /-->')))
|
||||
->andReturn(self::used());
|
||||
$ability = $this->ability($usages, self::snapshots());
|
||||
$input = ['items' => [['id' => 'holz DUNKEL', 'color' => '#000000', 'label' => 'Holz schwarz']]];
|
||||
|
||||
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
|
||||
|
||||
self::assertSame(['changed'], array_column($dry['diff'], 'action'));
|
||||
self::assertSame('gcid-holz000001', $dry['diff'][0]['id']);
|
||||
self::assertSame(7, $dry['affected'][0]['post_id']);
|
||||
self::assertSame(1, $dry['affected_count']);
|
||||
|
||||
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||||
|
||||
self::assertSame(['gcid-holz000001'], array_keys($this->saves[0][0]));
|
||||
self::assertSame(
|
||||
['#000000', 'Holz schwarz'],
|
||||
[$this->stored['gcid-holz000001']['color'], $this->stored['gcid-holz000001']['label']]
|
||||
);
|
||||
}
|
||||
|
||||
public function testDeactivatingIsHowAColorIsRemoved(): void
|
||||
{
|
||||
$dry = self::assertToolError(
|
||||
$this->ability()->execute(['items' => [['id' => 'gcid-holz000001', 'status' => 'inactive']]]),
|
||||
'confirm_required'
|
||||
);
|
||||
|
||||
self::assertSame(['deactivated'], array_column($dry['diff'], 'action'));
|
||||
}
|
||||
|
||||
public function testAnItemThatChangesNothingIsNoChange(): void
|
||||
{
|
||||
$result = $this->ability()->execute(['items' => [['id' => 'Holz dunkel', 'color' => '#5b3a29']]]);
|
||||
|
||||
self::assertSame([], $result['diff']);
|
||||
self::assertStringStartsWith('Keine Änderung', $result['summary']);
|
||||
self::assertSame([], $this->saves);
|
||||
}
|
||||
|
||||
/** Ruling V3: a main color is found by its option name and only its value is written. */
|
||||
public function testMainColorsChangeOnlyTheirValue(): void
|
||||
{
|
||||
$ability = $this->ability(null, self::snapshots());
|
||||
$input = ['items' => [['id' => 'accent_color', 'color' => '#123456']]];
|
||||
|
||||
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
|
||||
self::assertSame('gcid-primary-color', $dry['diff'][0]['id']);
|
||||
|
||||
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||||
|
||||
self::assertSame(['gcid-primary-color'], array_keys($this->saves[0][0]));
|
||||
self::assertSame('#123456', $this->stored['gcid-primary-color']['color']);
|
||||
}
|
||||
|
||||
public function testMainColorsCannotBeRenamedMovedOrDeactivated(): void
|
||||
{
|
||||
$error = $this->ability()->execute(['items' => [
|
||||
['id' => 'primary', 'label' => 'Haupt', 'folder' => 'Basis', 'status' => 'inactive'],
|
||||
]]);
|
||||
|
||||
self::assertToolError($error, 'validation_failed');
|
||||
self::assertSame(
|
||||
['items[0].label', 'items[0].folder', 'items[0].status'],
|
||||
array_keys(self::errorFields($error))
|
||||
);
|
||||
}
|
||||
|
||||
public function testAllProblemsOfAllItemsAreCollected(): void
|
||||
{
|
||||
$error = $this->ability()->execute(['items' => [
|
||||
['id' => 'Fehlt', 'color' => '#ffffff'],
|
||||
['label' => '', 'color' => 'kein Wert!'],
|
||||
['color' => '#ffffff'],
|
||||
['label' => 'Holz dunkel', 'color' => '#ffffff'],
|
||||
['id' => 'gcid-holz000001', 'folder' => ['x'], 'status' => 'gelöscht'],
|
||||
'kein Objekt',
|
||||
]]);
|
||||
|
||||
self::assertToolError($error, 'validation_failed');
|
||||
self::assertSame(
|
||||
['items[0].id', 'items[1].label', 'items[1].color', 'items[2].label', 'items[3].label', 'items[4].folder',
|
||||
'items[4].status', 'items[5]'],
|
||||
array_keys(self::errorFields($error))
|
||||
);
|
||||
self::assertSame([], $this->saves);
|
||||
}
|
||||
|
||||
public function testAFailedWriteIsRolledBack(): void
|
||||
{
|
||||
$calls = [];
|
||||
$colors = Mockery::mock(GlobalColorsAdapter::class);
|
||||
$colors->allows('read')->andReturn(ColorAreaTest::stored());
|
||||
$colors->allows('save')->andReturnUsing(static function (array $entries, bool $replace) use (&$calls): void {
|
||||
$calls[] = $replace;
|
||||
if (count($calls) === 1) {
|
||||
throw new RuntimeException('Divi hat nichts gespeichert');
|
||||
}
|
||||
});
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->expects('save')->andReturn('colors-1');
|
||||
$ability = $this->ability(null, $snapshots, [], $colors);
|
||||
$input = ['items' => [['id' => 'Holz dunkel', 'color' => '#000000']]];
|
||||
$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('colors-1', $e->details['snapshot_id']);
|
||||
}
|
||||
self::assertSame([false, true], $calls, 'the merge failed, the snapshot replaced all colors');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user