diff --git a/src/Integrations/Divi/Design/DiviColorsSetAbility.php b/src/Integrations/Divi/Design/DiviColorsSetAbility.php index 80656b7..0baec8b 100644 --- a/src/Integrations/Divi/Design/DiviColorsSetAbility.php +++ b/src/Integrations/Divi/Design/DiviColorsSetAbility.php @@ -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 $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> $diff + * @return list + */ + 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 $names */ + private static function names(array $names): string + { + return implode(', ', array_slice($names, 0, self::LISTED)) . (count($names) > self::LISTED ? ', …' : ''); + } } diff --git a/tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php index bc19601..9e3e1f5 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php @@ -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']]); + } }