feat: lit/divi-colors-set löst {{color:…}} mit relative gegen den geplanten Bestand auf, lehnt Zyklen ab und warnt vor Verwendungen in Presets und relativen Farben
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
337b87b572
commit
2fd03f3900
@@ -9,7 +9,9 @@ use InvalidArgumentException;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||||
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||||
use WP_Error;
|
||||
@@ -20,8 +22,10 @@ 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).
|
||||
* their value, never to a reference. {{color:…}} with relative is resolved against the planned inventory – also
|
||||
* colors of the same call, in item order – and stored as Divi reference (VariableRef); cycles and chains deeper than
|
||||
* 10 are refused. The dry run lists the posts that reference a changed, deactivated or activated color
|
||||
* (ReferenceUsageQuery) and warns about presets and relative colors built on it.
|
||||
*/
|
||||
final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
||||
{
|
||||
@@ -39,6 +43,17 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
||||
private const NEW_ENTRY = ['color' => '', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active',
|
||||
'usedInPosts' => []];
|
||||
|
||||
private const PLACEHOLDER = '/^\{\{\s*color\s*:\s*([^{}]+?)\s*\}\}$/D';
|
||||
|
||||
/** Exception code of value(): the problem belongs to the item's relative, not to its color. */
|
||||
private const RELATIVE = 1;
|
||||
|
||||
/** At most this many names per warning. */
|
||||
private const LISTED = 10;
|
||||
|
||||
private const IN_USE = 'Die unter affected genannten Inhalte verweisen auf geänderte oder deaktivierte Farben; '
|
||||
. 'deaktivierte Farben bleiben dort als Verweis stehen.';
|
||||
|
||||
/** @param (Closure(int): string)|null $random Random characters for new IDs (ColorBook::newId(); tests). */
|
||||
public function __construct(
|
||||
private DesignSystemWriter $writer,
|
||||
@@ -172,6 +187,13 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
||||
}
|
||||
$book = $this->apply($book, $item, $field, $errors);
|
||||
}
|
||||
$cycle = $errors === [] ? $book->cycle() : null;
|
||||
if ($cycle !== null) {
|
||||
$errors['items'] = sprintf(
|
||||
'Relative Farben bauen im Kreis aufeinander auf oder sind tiefer als 10 Stufen verschachtelt: %s',
|
||||
implode(' → ', array_map($book->label(...), $cycle))
|
||||
);
|
||||
}
|
||||
if ($errors !== []) {
|
||||
return Errors::invalidInput('Ungültige Farb-Angaben; nichts wurde gespeichert.', $errors);
|
||||
}
|
||||
@@ -184,12 +206,14 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
||||
}
|
||||
}
|
||||
$touched = array_values(array_unique($touched));
|
||||
$report = $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched));
|
||||
|
||||
return new DesignPlan(
|
||||
$book->data(),
|
||||
$diff,
|
||||
ColorDiff::summary($diff),
|
||||
$touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched))
|
||||
$report,
|
||||
$this->warnings($book, $diff, $report)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,12 +249,15 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
||||
'label' => self::cleanLabel($book, $item['label'], $id),
|
||||
'folder' => self::folder($item['folder']),
|
||||
'status' => self::status($item['status']),
|
||||
default => self::value($item),
|
||||
default => self::value($book, $item, $main),
|
||||
};
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$errors[$field . '.' . $key] = $e->getMessage();
|
||||
$errors[$field . '.' . ($e->getCode() === self::RELATIVE ? 'relative' : $key)] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
if (array_key_exists('relative', $item) && ! array_key_exists('color', $item)) {
|
||||
$errors[$field . '.relative'] = 'nur zusammen mit color als Platzhalter {{color:…}}';
|
||||
}
|
||||
if (count($errors) > $known || ($id !== null && $entry === $book->entry($id))) {
|
||||
return $book;
|
||||
}
|
||||
@@ -313,22 +340,52 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored value of $item's color (ruling V5): a checked fixed value, or {{color:…}} with relative resolved
|
||||
* against $book (the planned inventory) into a Divi reference. A main color only takes a fixed value – Divi keeps
|
||||
* it in et_divi, where a reference would break the Customizer.
|
||||
*
|
||||
* @param array<array-key, mixed> $item
|
||||
* @throws InvalidArgumentException
|
||||
* @throws InvalidArgumentException Code RELATIVE when the problem is in relative.
|
||||
*/
|
||||
private static function value(array $item): string
|
||||
private static function value(ColorBook $book, array $item, bool $main): 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);
|
||||
if (preg_match(self::PLACEHOLDER, $color, $match) !== 1) {
|
||||
if (array_key_exists('relative', $item)) {
|
||||
throw new InvalidArgumentException('nur zusammen mit einem Platzhalter {{color:…}}', self::RELATIVE);
|
||||
}
|
||||
$problem = ColorValue::check($color);
|
||||
if ($problem !== null) {
|
||||
throw new InvalidArgumentException($problem);
|
||||
}
|
||||
|
||||
return $color;
|
||||
}
|
||||
|
||||
return $color;
|
||||
if ($main) {
|
||||
throw new InvalidArgumentException('Hauptfarben brauchen einen festen Farbwert, keinen Platzhalter');
|
||||
}
|
||||
$base = $book->find($match[1]);
|
||||
if ($base === null) {
|
||||
throw new InvalidArgumentException(sprintf('unbekannte Farbe „%s“ im Platzhalter', $match[1]));
|
||||
}
|
||||
$relative = $item['relative'] ?? null;
|
||||
if (! is_array($relative)) {
|
||||
throw new InvalidArgumentException(
|
||||
'Pflicht mit einem Platzhalter: {hue, saturation, lightness, opacity}',
|
||||
self::RELATIVE
|
||||
);
|
||||
}
|
||||
$settings = ColorValue::relative($relative);
|
||||
if (is_string($settings)) {
|
||||
throw new InvalidArgumentException($settings, self::RELATIVE);
|
||||
}
|
||||
|
||||
return VariableRef::serialize('color', $base, $settings);
|
||||
}
|
||||
|
||||
private function newId(ColorBook $book): string
|
||||
@@ -339,4 +396,57 @@ final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruling V8: presets and relative colors built on a changed, deactivated or activated color are warnings, never
|
||||
* a block.
|
||||
*
|
||||
* @param list<array<string, mixed>> $diff
|
||||
* @return list<string>
|
||||
*/
|
||||
private function warnings(ColorBook $book, array $diff, ?UsageReport $report): array
|
||||
{
|
||||
$presets = null;
|
||||
$warnings = [];
|
||||
$deactivates = false;
|
||||
foreach ($diff as $row) {
|
||||
if ($row['action'] === 'added') {
|
||||
continue;
|
||||
}
|
||||
$deactivated = $row['action'] === 'deactivated';
|
||||
$deactivates = $deactivates || $deactivated;
|
||||
$presets ??= PresetBook::fromDivi($this->writer->presets());
|
||||
$users = array_column($presets->referencing((string) $row['id']), 'name');
|
||||
if ($users !== []) {
|
||||
$warnings[] = sprintf(
|
||||
$deactivated
|
||||
? 'Farbe „%s“ wird deaktiviert, aber noch von Presets verwendet: %s.'
|
||||
: 'Die Änderung an Farbe „%s“ wirkt auch auf die Presets %s.',
|
||||
$row['name'],
|
||||
self::names($users)
|
||||
);
|
||||
}
|
||||
$derived = array_map($book->label(...), $book->referencing((string) $row['id']));
|
||||
if ($derived !== []) {
|
||||
$warnings[] = sprintf(
|
||||
$deactivated
|
||||
? 'Farbe „%1$s“ wird deaktiviert, aber die relativen Farben %2$s bauen auf ihr auf.'
|
||||
: 'Die relativen Farben %2$s bauen auf Farbe „%1$s“ auf und ändern sich mit.',
|
||||
$row['name'],
|
||||
self::names($derived)
|
||||
);
|
||||
}
|
||||
}
|
||||
if ($deactivates && $report !== null && $report->exists()) {
|
||||
$warnings[] = self::IN_USE;
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/** @param list<string> $names */
|
||||
private static function names(array $names): string
|
||||
{
|
||||
return implode(', ', array_slice($names, 0, self::LISTED)) . (count($names) > self::LISTED ? ', …' : '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ 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\Compose\VariableRef;
|
||||
use LIT\MCP\Integrations\Divi\Design\ColorBook;
|
||||
use LIT\MCP\Integrations\Divi\Design\ColorDiff;
|
||||
use LIT\MCP\Integrations\Divi\Design\ColorValue;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||
use LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility;
|
||||
@@ -300,4 +302,140 @@ final class DiviColorsSetAbilityTest extends TestCase
|
||||
}
|
||||
self::assertSame([false, true], $calls, 'the merge failed, the snapshot replaced all colors');
|
||||
}
|
||||
|
||||
/** Ruling V5: {{color:…}} with relative is stored as Divi reference on the named color's ID. */
|
||||
public function testAPlaceholderBecomesARelativeColorOfTheNamedColor(): void
|
||||
{
|
||||
$ability = $this->ability(null, self::snapshots());
|
||||
$input = ['items' => [
|
||||
['label' => 'Holz hell', 'color' => '{{color:Holz dunkel}}', 'relative' => ['lightness' => 30]],
|
||||
]];
|
||||
|
||||
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
|
||||
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||||
|
||||
$created = array_values(array_diff_key($this->stored, ColorAreaTest::stored()));
|
||||
self::assertSame(
|
||||
VariableRef::serialize('color', 'gcid-holz000001', ColorValue::relative(['lightness' => 30])),
|
||||
$created[0]['color']
|
||||
);
|
||||
}
|
||||
|
||||
/** Ruling V5: the placeholder is resolved against the planned inventory, also colors of the same call. */
|
||||
public function testAPlaceholderMayNameAColorOfTheSameCall(): void
|
||||
{
|
||||
$ability = $this->ability(null, self::snapshots());
|
||||
$input = ['items' => [
|
||||
['label' => 'Basis', 'color' => '#112233'],
|
||||
['label' => 'Basis hell', 'color' => '{{color:basis}}', 'relative' => ['lightness' => 20]],
|
||||
]];
|
||||
|
||||
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
|
||||
self::assertSame(['added', 'added'], array_column($dry['diff'], 'action'));
|
||||
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||||
|
||||
$byLabel = [];
|
||||
foreach ($this->stored as $id => $entry) {
|
||||
$byLabel[$entry['label']] = ['id' => $id] + $entry;
|
||||
}
|
||||
self::assertSame(
|
||||
VariableRef::serialize('color', $byLabel['Basis']['id'], ColorValue::relative(['lightness' => 20])),
|
||||
$byLabel['Basis hell']['color']
|
||||
);
|
||||
}
|
||||
|
||||
public function testPlaceholderProblemsAreCollected(): void
|
||||
{
|
||||
$error = $this->ability()->execute(['items' => [
|
||||
['label' => 'A', 'color' => '{{color:Fehlt}}', 'relative' => ['lightness' => 10]],
|
||||
['label' => 'B', 'color' => '#ffffff', 'relative' => ['lightness' => 10]],
|
||||
['id' => 'primary', 'color' => '{{color:Holz dunkel}}', 'relative' => ['lightness' => 10]],
|
||||
['label' => 'C', 'color' => '{{color:Holz dunkel}}', 'relative' => ['farbe' => 10]],
|
||||
['id' => 'Sand', 'relative' => ['lightness' => 10]],
|
||||
['label' => 'D', 'color' => VariableRef::serialize('color', 'gcid-holz000001', [])],
|
||||
['label' => 'E', 'color' => '{{color:Holz dunkel}}'],
|
||||
]]);
|
||||
|
||||
self::assertToolError($error, 'validation_failed');
|
||||
self::assertSame(
|
||||
['items[0].color', 'items[1].relative', 'items[2].color', 'items[3].relative', 'items[4].relative',
|
||||
'items[5].color', 'items[6].relative'],
|
||||
array_keys(self::errorFields($error))
|
||||
);
|
||||
}
|
||||
|
||||
/** Ruling V5: Sand is built on Holz dunkel – Holz dunkel built on Sand would be a cycle. */
|
||||
public function testRelativeColorsMustNotFormACycle(): void
|
||||
{
|
||||
$error = $this->ability()->execute(['items' => [
|
||||
['id' => 'Holz dunkel', 'color' => '{{color:Sand}}', 'relative' => ['lightness' => -40]],
|
||||
]]);
|
||||
|
||||
self::assertToolError($error, 'validation_failed');
|
||||
$fields = self::errorFields($error);
|
||||
self::assertSame(['items'], array_keys($fields));
|
||||
self::assertStringContainsString('Sand', $fields['items']);
|
||||
self::assertSame([], $this->saves);
|
||||
}
|
||||
|
||||
/** Ruling V8: presets and relative colors built on a changed color are warnings, never a block. */
|
||||
public function testChangesWarnAboutPresetsAndRelativeColorsBuiltOnTheColor(): void
|
||||
{
|
||||
$dry = self::assertToolError(
|
||||
$this->ability(null, null, PresetBookTest::storedWithReferences())
|
||||
->execute(['items' => [['id' => 'Holz dunkel', 'color' => '#000000']]]),
|
||||
'confirm_required'
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'Die Änderung an Farbe „Holz dunkel“ wirkt auch auf die Presets CTA primär.',
|
||||
'Die relativen Farben Sand bauen auf Farbe „Holz dunkel“ auf und ändern sich mit.',
|
||||
], $dry['warnings']);
|
||||
}
|
||||
|
||||
public function testDeactivatingAUsedColorWarnsAndListsTheUsages(): void
|
||||
{
|
||||
$dry = self::assertToolError(
|
||||
$this->ability(self::usages(self::used()), null, PresetBookTest::storedWithReferences())
|
||||
->execute(['items' => [['id' => 'Holz dunkel', 'status' => 'inactive']]]),
|
||||
'confirm_required'
|
||||
);
|
||||
|
||||
self::assertSame(7, $dry['affected'][0]['post_id']);
|
||||
self::assertSame([
|
||||
'Farbe „Holz dunkel“ wird deaktiviert, aber noch von Presets verwendet: CTA primär.',
|
||||
'Farbe „Holz dunkel“ wird deaktiviert, aber die relativen Farben Sand bauen auf ihr auf.',
|
||||
'Die unter affected genannten Inhalte verweisen auf geänderte oder deaktivierte Farben; deaktivierte '
|
||||
. 'Farben bleiben dort als Verweis stehen.',
|
||||
], $dry['warnings']);
|
||||
}
|
||||
|
||||
public function testNewColorsHaveNoWarnings(): void
|
||||
{
|
||||
$dry = self::assertToolError(
|
||||
$this->ability(null, null, PresetBookTest::storedWithReferences())
|
||||
->execute(['items' => [['label' => 'Weiß', 'color' => 'white']]]),
|
||||
'confirm_required'
|
||||
);
|
||||
|
||||
self::assertSame([], $dry['warnings']);
|
||||
}
|
||||
|
||||
/** Review note: ColorBook::with() replaces, so a changed color keeps usedInPosts and its other fields. */
|
||||
public function testChangingAColorKeepsItsUsedInPostsAndOtherFields(): void
|
||||
{
|
||||
$this->stored['gcid-holz000001']['usedInPosts'] = [7, 9];
|
||||
$ability = $this->ability();
|
||||
$input = ['items' => [['id' => 'Holz dunkel', 'color' => '#000000']]];
|
||||
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->allows('save')->andReturn('colors-1');
|
||||
$snapshots->allows('attachLog');
|
||||
|
||||
$this->ability(null, $snapshots)->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||||
|
||||
$saved = $this->saves[0][0]['gcid-holz000001'];
|
||||
self::assertSame([7, 9], $saved['usedInPosts']);
|
||||
self::assertSame(['Holz dunkel', '#000000'], [$saved['label'], $saved['color']]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user