Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
10158 lines
458 KiB
Markdown
10158 lines
458 KiB
Markdown
# Etappe 4c – Designsystem: Farben und Variablen schreiben – Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Der KI-Assistent legt globale Farben und Variablen an und ändert sie (`lit/divi-colors-set`, `lit/divi-variables-set`), entfernt Verweise auf eine Variable überall, indem er sie durch ihren Wert ersetzt (`lit/divi-design-detach` mit `variable`), und stellt Snapshots der Bereiche `colors` und `variables` wieder her (`lit/divi-design-restore`) – alles mit Probelauf, `confirm`, `design_rev`, Snapshot vor jeder Änderung und Zurücklesen nach dem Schreiben.
|
||
|
||
**Architecture:** Die 4a-Pipeline `DesignChange` bleibt unverändert; neu sind zwei `DesignArea`-Implementierungen (`ColorArea`, `VariableArea`) mit reinen Bestandsklassen (`ColorBook`, `VariableBook`, jeweils mit Diff und Wertprüfung) und zwei kleinen Divi-Adaptern (`GlobalColorsAdapter`, `GlobalVariablesAdapter`) mit eigenen Fähigkeiten (`color-write`, `variable-write`). Fundstellen von Farben/Variablen in Seiten liefert eine neue `ReferenceUsageQuery` für das vorhandene `GlobalUsages`; Variablen-Detach nutzt den vorhandenen `PageRewriter` mit einer neuen `NodeRewrite`-Strategie und den Preset-Schritt der 4b-Pipeline. `DiviDesignRestoreAbility` wird auf Planer je Bereich umgestellt (`RestorePlanner`), `DiviDesignDetachAbility` verwendet statt eines Arrays ein `DetachTarget`.
|
||
|
||
**Tech Stack:** PHP ≥ 8.1, WordPress 7.x, Divi 5.15.1 (`GlobalData::get_global_colors/set_global_colors/sanitize_global_colors_data/get_global_variables/set_global_variables`, `et_pb_is_allowed` – nur über die zwei neuen Adapter), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq-Smokes.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` (§4, §5, §6.8 Variable, §7, §10, §11, §13, §14 Schritt 6), Entscheidungen `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` (G2, G5, G6, A4, A7, A8 – verbindlich), Datenformate `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` (§1 Farben, §2 Variablen, §5 Caches, §6 Rechte, §7 Abweichungen), Divi-Fähigkeiten `docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md`, Pläne 4a/4b (`2026-10-16-etappe-4a-presets.md`, `2026-10-18-etappe-4b-zuweisen.md`) als Vorbild; der Code ist maßgeblich, wo er von den Plänen abweicht.
|
||
|
||
**Voraussetzung:** Branch `etappe-4`, Stand 63389f3 (4a, 4b und Divi-Fähigkeiten fertig), 2877 Unit-Tests grün (`composer test`), `composer lint` sauber.
|
||
|
||
**Teil von Etappe 4:** 4a, 4b (fertig) → **4c (dieser Plan)** → 4d Theme-Optionen und Schriften → 4e Schleifen-Inhalte, Guides, Audit, Version.
|
||
|
||
## Global Constraints
|
||
|
||
- **Dateien nur im Plugin-Verzeichnis** `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte schreiben nur in die DDEV-Datenbank und stellen im EXIT-Trap alles wieder her, was sie ändern (Farben, Variablen, Presets, Seiten).
|
||
- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9; Divi 5.15.1. Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, jede Datei mit `defined('ABSPATH') || exit;`, höchstens 120 Zeichen je Zeile, `declare(strict_types=1);`. Nach jedem Task `composer test` und `composer lint` sauber (`composer test` = `php -d memory_limit=512M vendor/bin/phpunit`).
|
||
- **Divi-Grenze (Divi-Absicherung §3):** Divi-Aufrufe nur in Klassen aus `DiviAdapters::CLASSES` mit Manifest; jedes Divi-Symbol steht mit Fähigkeiten im Manifest; `DiviBoundaryTest` und `DiviFeatureAssignmentTest` bleiben grün (die angepasste Tabelle gehört in denselben Task wie die Manifest-Änderung). Alle übrigen neuen Klassen sind **reines PHP** ohne WordPress- und Divi-Aufrufe (Abhängigkeiten als Objekt oder `Closure` injiziert); Ausnahmen nennt der Task ausdrücklich (`ReferenceUsageQuery` darf wie `PresetUsageQuery` nur `$db->prepare()`/`$db->esc_like()` am übergebenen `\wpdb` nutzen).
|
||
- **Pipeline (Spec §4):** Jede Änderung eines Bereichs läuft durch `DesignChange::run()`: Rechte → Eingabe prüfen (alle Fehler gesammelt als `validation_failed` mit Feldpfaden) → neuen Bestand berechnen → Probelauf (`confirm_required` mit `diff`, `design_rev`, `affected`, `warnings`) → mit `confirm: true` und `design_rev` Sperre, Snapshot, Schreiben über Divi, Zurücklesen und `verify`, Protokoll. Keine Ausnahme für „harmlose“ Änderungen. Mehrere Einträge je Aufruf (A4, höchstens 50).
|
||
- **Werte (D7-Prinzip):** Farbwerte und Variablenwerte werden vom Plugin geprüft (`ColorValue`, `VariableValue`) und erst dann an Divi gegeben; Divis eigenes Sanitize (`sanitize_global_colors_data`, `sanitize_global_variables_data` innerhalb von `set_global_variables`) läuft zusätzlich. Referenzen auf andere Farben entstehen nur über `VariableRef::serialize('color', <id>, <settings>)`, nie aus Client-Text.
|
||
- **Rechte (Spec §10):** Farben = `edit_theme_options` **und** Divi-Rolle „Visual Builder verwenden“ (strenger als Divi selbst); Variablen zusätzlich `et_pb_is_allowed('variables_manager')` (sonst täte Divi still nichts). Restore prüft die Rechte des Bereichs des Snapshots. Detach einer Variable = Rechte der Variablen **und** `edit_post` je Live-Fundstelle (Regeln P8/P8a des `PageRewriter`) und, weil Presets mitgeschrieben werden, `DesignRights::presets()`.
|
||
- **Exposition:** Neue Tools sind indirekt (über `mcp-adapter-execute-ability`), Gruppe `divi`, `Annotations::write`/`destructive` wie die Preset-Tools; Texte nennen indirekte Tools `lit/<name>` (ToolNamingTest); `docs/guides/overview.md` „Direkt sichtbar:“ bleibt unverändert. Guides folgen gesammelt mit 4e (Spec §14 Schritt 9); die Tool-Beschreibungen tragen die Reihenfolge Probelauf → `confirm`.
|
||
- **Sprache:** Meldungen, Hinweise, Beschreibungen auf Deutsch mit echten Umlauten; neutral „KI-Assistent“, „MCP-Client“.
|
||
- **Tests:** Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery, `assertToolError`); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen. Reine Klassen erben von `PHPUnit\Framework\TestCase`. TDD: Test zuerst, RED-Lauf mit erwarteter Fehlermeldung, dann Code, GREEN. Integration: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh` (läuft nur mit deaktivierten Plugins `postlos` und `asynclistry`).
|
||
- **Kein clean-code-review-Skill je Task** (die globale CLAUDE.md-Regel zieht sonst die Antwort des Implementierers an sich); der Gesamtreview folgt am Ende (Task 12).
|
||
- Commits: `git commit -m "<Nachricht>" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"` mit dem Namen des ausführenden Modells; nur die Dateien des Tasks stagen.
|
||
|
||
## Entscheidungen für 4c (Rulings, verbindlich)
|
||
|
||
- **V1 Variablen werden nie gelöscht, nur archiviert.** Divi 5.15.1 lehnt in `GlobalData::set_global_variables()` jeden Bestand ab, dem eine gespeicherte `gvid-`-ID fehlt (`validate_global_variable_ids()` → `global_variable_ids_missing`, 400). Echtes Löschen (Spec §7.2 `delete: true`) ist damit nicht möglich und entfällt; der Builder archiviert ebenfalls (`status: archived`, `order: ""`). Folgen: (a) `lit/divi-variables-set` kennt `status: active|archived`, kein `delete`, kein `in_use`; Archivieren einer verwendeten Variablen ist erlaubt und meldet die Fundstellen als Warnung (wie Farben deaktivieren, Spec §7.1); (b) **Restore** eines Snapshots stellt jede Variable des Snapshots her; Variablen, die erst nach dem Snapshot entstanden, bleiben **archiviert** erhalten (`VariableBook::restoredFrom()`), der Probelauf nennt sie als Warnung; (c) `lit/divi-design-detach` mit `variable` ist der Weg, eine Variable aus den Inhalten zu lösen, bevor man sie archiviert. Spec §7.2 und Entscheidungsnotiz werden entsprechend nachgeführt (Task 11).
|
||
- **V2 Zwei Fähigkeiten statt einer:** `DiviFeature::ColorWrite` (`color-write`, „Farben schreiben“) und `DiviFeature::VariableWrite` (`variable-write`, „Variablen schreiben“). Ein Bruch von `set_global_variables` sperrt so nicht das Farbenschreiben. `DesignArea::feature()` liefert je Bereich die eigene Fähigkeit; beide sind Schreib-Fähigkeiten (`isWrite()`). Die Tools deklarieren zusätzlich `DesignRead` (und das Detach-Tool die Seiten-/Bibliotheks-/Theme-Builder-Fähigkeiten wie bisher).
|
||
- **V3 Farben: Hauptfarben und Cache.** `GlobalData::get_global_colors()` liefert die fünf Customizer-Farben (`folder: customizer`, IDs `gcid-primary-color` … `gcid-link-color`, Optionen `accent_color`, `secondary_accent_color`, `header_color`, `font_color`, `link_color`) **vor** den gespeicherten Farben mit `lastUpdated` = jetzt, und `get_customizer_colors()` hat einen `static`-Cache ohne Reset. `GlobalColorsAdapter::read()` legt deshalb die Werte der fünf Hauptfarben aus der Option `et_divi` über das Ergebnis (nur wenn dort ein nicht leerer String steht) – sonst läse das Zurücklesen nach dem Schreiben im selben Request den alten Wert. `ColorBook` setzt `lastUpdated` der Hauptfarben auf `""` (flüchtig; `design_rev` und Snapshots bleiben stabil). Geschrieben werden Hauptfarben nur, wenn sich ihr Wert ändert (die Builder-Sicht `#2ea3f2` für `secondary_accent_color` weicht vom Customizer-Standard `#8800FF` ab, Formate §4). Hauptfarben lassen sich nicht deaktivieren und nicht umbenennen oder in einen Ordner schieben; nur `color` ändert sich.
|
||
- **V4 Schreibweg Farben:** normales Schreiben = nur geänderte und neue Einträge (`ColorBook::changedSince()`), durch `GlobalData::sanitize_global_colors_data()` und dann `set_global_colors($data, true)` (mischt; `true` heißt „bereits bereinigt“); Restore (`mayRemove = true`) = gesamter Snapshot durch `set_global_colors($data, false)` (ersetzt alle gespeicherten Farben, bereinigt selbst, schreibt die Hauptfarben zurück). Neue IDs `gcid-` + 10 Zeichen `[0-9a-z]`; `lastUpdated` im Format `Y-m-d\TH:i:s.v\Z`; `usedInPosts` bleibt `[]` (nur der Builder pflegt sie, Formate §1).
|
||
- **V5 Farbwerte:** `color` = Hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`), `rgb()`/`rgba()`/`hsl()`/`hsla()`, ein Farbschlüsselwort (nur Buchstaben, 3–20 Zeichen) oder ein Platzhalter `{{color:<Name|ID|Alias>}}` **mit** `relative: {hue?, saturation?, lightness?, opacity?}` (Zahlen, `hue` −360…360, die übrigen −100…100). Der Platzhalter wird gegen den **geplanten** Bestand aufgelöst (auch Farben desselben Aufrufs, in Reihenfolge der `items`) und als `VariableRef::serialize('color', <gcid>, <settings>)` gespeichert. Zyklen und Tiefe über 10 (wie Divi) → `validation_failed`. `shortcut:` Wertebereiche der relativen Einstellungen sind die der Builder-Regler, nicht aus Divi-Code abgeleitet – im Code kommentieren.
|
||
- **V6 Variablenwerte (`VariableValue::check(type, value)`):** `numbers` = CSS-Ausdruck ohne `;`, `{`, `}`, `<`, `>`, Zeilenumbruch, höchstens 200 Zeichen, nicht leer; `strings` = beliebiger Text ohne Steuerzeichen außer Zeilenumbruch, höchstens 2000 Zeichen, nicht leer; `links` = `http(s)://`- oder `/`-URL, `mailto:`/`tel:` erlaubt, höchstens 2000 Zeichen; `images` = Anhang-ID (Ziffern) oder `http(s)://`-URL; `fonts` = Schriftname, 1–100 Zeichen ohne `;{}<>"'\\` (die Prüfung gegen Divis Schriftenliste folgt mit 4d). Aktive Variablen brauchen einen Wert (Divi: `global_variable_active_value_empty`). Der Typ `gradients` (neu in Divi 5.15) wird beim Lesen und Schreiben **unverändert durchgereicht**, das Tool legt ihn aber nicht an.
|
||
- **V7 Schreibweg Variablen:** `GlobalVariablesAdapter::read()` = `GlobalData::get_global_variables()` zurück in Speicherform (Gruppen und Einträge als Arrays, `allowedActions`, die Customizer-Schriften `--et_global_heading_font`/`--et_global_body_font` und die Gruppe `colors` entfernt, leere Gruppen weggelassen); `save($data, $expected)` ruft `GlobalData::set_global_variables($data, $expected)` mit dem Bestand, auf dem geplant wurde (Divis eigene Stale-Prüfung `global_variables_stale`), und macht aus einem `WP_Error` eine `RuntimeException` (die Pipeline rollt zurück). Ohne `variables_manager`/`edit_theme_options` tut Divi **still nichts** – darum prüft `DesignRights::variables()` vorher, und `VariableArea::verify()` fängt den Rest über das Zurücklesen.
|
||
- **V8 Fundstellen (`affected`):** Für geänderte, deaktivierte oder archivierte Einträge (nicht für neue) liefert `ReferenceUsageQuery` die Seiten, Bibliothekseinträge und Theme-Builder-Layouts, die die ID in einem `$variable(…)$`-Verweis nennen (über `GlobalUsages::reportFor()`, Rechte-Regeln wie `UsageReport`); Verwendungen in Presets und in relativen Farben stehen als `warnings`, nie als Sperre. Eine blockierte Live-Seite (ohne `edit_post`) verweigert das Schreiben wie bei Presets (`DesignChange::blocked()`, P8/P8a).
|
||
- **V9 Detach einer Variablen (A7, R10):** jeder ganze Wert `$variable({"type":"content","value":{"name":"<gvid>",…}})$` in den Attributen eines lesbaren Blocks wird durch den aktuellen Wert der Variablen ersetzt (bei `strings` mit `before`/`after` aus den Einstellungen: `before . Wert . after`; sonst ohne). Eingebettete Verweise (nicht der ganze Wert) bleiben stehen und werden als Hinweis gemeldet. Verschachtelte Verwendung in Presets (`attrs`, `styleAttrs`, `renderAttrs`) entfernt der Aufruf im selben Schritt über die Pipeline (Snapshot `presets`), wie bei Gruppen-Presets (4b R10). Farben haben kein Detach (sie werden nur deaktiviert).
|
||
- **V10 Restore je Bereich:** `DiviDesignRestoreAbility` bekommt eine Liste von `RestorePlanner` (`PresetRestorePlanner` = der bisherige Code unverändert verschoben, `ColorRestorePlanner`, `VariableRestorePlanner`); der Bereich ergibt sich aus der Snapshot-ID. Rechte und Fähigkeit sind die des Bereichs. Die Meldung „Snapshots dieses Bereichs lassen sich noch nicht wiederherstellen“ gilt nur noch für `theme_options` (4d).
|
||
|
||
## Schnittstellenvertrag (Namen und Signaturen, die spätere Tasks voraussetzen)
|
||
|
||
Alle Klassen unter `src/Integrations/Divi/Design/` (Namespace `LIT\MCP\Integrations\Divi\Design`), Tests unter `tests/Unit/Integrations/Divi/Design/` (Namespace `LIT\MCP\Tests\Unit\Integrations\Divi\Design`), sofern nicht anders genannt.
|
||
|
||
```php
|
||
// Release/DiviFeature.php (Task 1)
|
||
case ColorWrite = 'color-write'; // label 'Farben schreiben', isWrite() = true
|
||
case VariableWrite = 'variable-write'; // label 'Variablen schreiben', isWrite() = true
|
||
|
||
// GlobalColorsAdapter.php (Task 2) – class (nicht final: Mockery-Ersatz in Tests) implements DiviAdapter, Manifest::DESIGN
|
||
public static function manifest(): Manifest;
|
||
/** @return array<string, array<string, mixed>> get_global_colors() mit den Hauptfarben aus et_divi (V3) */
|
||
public function read(): array;
|
||
/** @param array<string, array<string, mixed>> $entries bereits geprüft; wirft RuntimeException, wenn Divi nichts speichert */
|
||
public function save(array $entries, bool $replace): void; // replace=false: bereinigen + set_global_colors($d, true); true: set_global_colors($d, false)
|
||
|
||
// GlobalVariablesAdapter.php (Task 2)
|
||
public static function manifest(): Manifest;
|
||
/** @return array<string, array<string, array<string, mixed>>> Speicherform, Typ => ID => Eintrag (V7) */
|
||
public function read(): array;
|
||
/** @param array<string, array<string, array<string, mixed>>> $data @param array<string, array<string, array<string, mixed>>> $expected */
|
||
public function save(array $data, array $expected): void; // wirft RuntimeException bei WP_Error von Divi
|
||
public function canManage(): bool; // et_pb_is_allowed('variables_manager'), fail closed ohne Divi
|
||
|
||
// DesignRights.php (Task 2) – zusätzlich zu presets()
|
||
public static function colors(DesignSystemWriter $writer): ?WP_Error; // edit_theme_options + Visual Builder
|
||
public static function variables(DesignSystemWriter $writer, GlobalVariablesAdapter $variables): ?WP_Error; // + variables_manager
|
||
|
||
// ColorValue.php (Task 3) – reines PHP
|
||
public const TYPES_NOTE = '…';
|
||
public static function check(string $color): ?string; // null = gültig, sonst deutsche Fehlermeldung (V5; ohne Platzhalter)
|
||
/** @param array<string, mixed> $relative @return array<string, int|float>|string settings oder Fehlermeldung */
|
||
public static function relative(array $relative): array|string; // nur hue|saturation|lightness|opacity, Zahlen, Bereiche V5
|
||
|
||
// ColorBook.php (Task 3) – reines PHP, unveränderlich
|
||
public const CUSTOMIZER_IDS = ['gcid-primary-color' => 'accent_color', 'gcid-secondary-color' => 'secondary_accent_color',
|
||
'gcid-heading-color' => 'header_color', 'gcid-body-color' => 'font_color', 'gcid-link-color' => 'link_color'];
|
||
public static function fromDivi(array $raw): self; // Eintrag => {color, folder, label, lastUpdated, status, usedInPosts}; Hauptfarben: lastUpdated ''
|
||
/** @return array<string, array{color: string, folder: string, label: string, lastUpdated: string, status: string, usedInPosts: list<mixed>}> */
|
||
public function data(): array;
|
||
public function rev(): string; // Rev::of() wie PresetBook::rev() (Hash über den kanonischen Bestand)
|
||
public function entry(string $id): ?array;
|
||
public function label(string $id): string; // Label, sonst ID
|
||
public function isCustomizer(string $id): bool; // folder === 'customizer' oder ID in CUSTOMIZER_IDS
|
||
/** @throws InvalidArgumentException mehrdeutiges Label (ID-Liste, wie DesignSystem::byLabel) */
|
||
public function find(string $needle): ?string; // ID | Alias primary/secondary/heading/body/link | Optionsname | Label (ohne Beachtung der Groß-/Kleinschreibung)
|
||
public function with(string $id, array $entry): self; // anlegen oder ersetzen
|
||
public function referencing(string $id): array; // list<string> IDs von Farben, deren color ein Verweis auf $id ist
|
||
public function cycle(): ?array; // list<string> Kette eines Zyklus oder einer Tiefe > 10, sonst null
|
||
public function changedSince(self $before): array; // array<string, entry> neue und geänderte Einträge (lastUpdated der Hauptfarben ignoriert)
|
||
public static function newId(?Closure $random = null): string; // 'gcid-' + 10 Zeichen [0-9a-z]; $random(int $n): string für Tests
|
||
public static function now(): string; // Y-m-d\TH:i:s.v\Z (UTC)
|
||
|
||
// ColorDiff.php (Task 3) – reines PHP
|
||
/** @return list<array{id: string, name: string, action: string, fields?: list<string>}> action: added|changed|deactivated|activated (der Restore-Planer ergänzt removed) */
|
||
public static function between(ColorBook $before, ColorBook $after): array;
|
||
public static function summary(array $diff): string; // 'Farben: 1 angelegt, 2 geändert, 1 deaktiviert.' / 'Farben: keine Änderung.'
|
||
|
||
// VariableValue.php (Task 4) – reines PHP
|
||
public const TYPES = ['numbers', 'strings', 'images', 'links', 'fonts']; // anlegbar (V6); 'gradients' nur durchgereicht
|
||
public static function check(string $type, string $value): ?string; // null = gültig, sonst deutsche Fehlermeldung
|
||
|
||
// VariableBook.php (Task 4) – reines PHP, unveränderlich
|
||
public static function fromDivi(array $raw): self; // akzeptiert Speicherform und get_global_variables()-Form (V7): stdClass/Objekte → Arrays, allowedActions, Customizer-Schriften und 'colors' entfernt, leere Gruppen weg
|
||
/** @return array<string, array<string, array<string, mixed>>> Typ => ID => {id, label, value, order, status, lastUpdated, variableType} */
|
||
public function data(): array;
|
||
public function rev(): string;
|
||
public function entry(string $type, string $id): ?array;
|
||
public function typeOf(string $id): ?string;
|
||
/** @return array{type: string, id: string}|null @throws InvalidArgumentException mehrdeutiges Label (ID-Liste) */
|
||
public function find(string $needle, ?string $type = null): ?array; // ID oder Label (ohne Beachtung der Groß-/Kleinschreibung), optional auf einen Typ beschränkt
|
||
public function with(string $type, string $id, array $entry): self;
|
||
public function nextOrder(string $type): string; // größte vorhandene order des Typs + 1 als String
|
||
public function restoredFrom(self $snapshot): self; // V1b: Snapshot ∪ nur-aktuelle Einträge als archiviert (order '')
|
||
/** @return list<array{type: string, id: string, label: string}> Einträge, die restoredFrom() archiviert hätte */
|
||
public function onlyHere(self $snapshot): array;
|
||
public static function newId(?Closure $random = null): string; // 'gvid-' + 10 Zeichen [0-9a-z]
|
||
public static function now(): string;
|
||
|
||
// VariableDiff.php (Task 4) – reines PHP
|
||
/** @return list<array{type: string, id: string, name: string, action: string, fields?: list<string>}> action: added|changed|archived|activated */
|
||
public static function between(VariableBook $before, VariableBook $after): array;
|
||
public static function summary(array $diff): string; // 'Variablen: 1 angelegt, 1 geändert, 1 archiviert.'
|
||
|
||
// ReferenceUsageQuery.php (Task 5) – implements Library\UsageQuery
|
||
public function __construct(array $ids); // list<string> gcid-/gvid-IDs, mindestens eine, sonst InvalidArgumentException
|
||
// matches(): Inhalt parsen (Parser/NodeIndex), true, wenn die Attribute irgendeines Blocks eine der IDs nennen; nicht parsbar → true (fail closed)
|
||
|
||
// PresetBook.php (Task 5) – zusätzlich
|
||
/** @return list<array{id: string, name: string, scope: string}> Presets, deren attrs/styleAttrs/renderAttrs die ID nennen */
|
||
public function referencing(string $id): array;
|
||
public function withVariableResolved(string $id, string $value, bool $affixes = false): self; // V9: ganze Verweise im Preset durch $value ersetzen (attrs, styleAttrs, renderAttrs); $affixes: before/after der Verweis-Einstellungen (nur strings)
|
||
|
||
// ColorArea.php / VariableArea.php (Tasks 6/7) – implements DesignArea
|
||
public function __construct(GlobalColorsAdapter $colors); // name() 'colors', label() 'Farben', feature() ColorWrite
|
||
public function __construct(GlobalVariablesAdapter $variables); // name() 'variables', label() 'Variablen', feature() VariableWrite
|
||
|
||
// DiviColorsSetAbility.php / DiviVariablesSetAbility.php (Tasks 6/7) – implements Ability, RequiresDiviFeatures
|
||
// name() 'lit/divi-colors-set' bzw. 'lit/divi-variables-set'; Eingabe items[] + DesignChange::inputProperties()
|
||
|
||
// DetachTarget.php (Task 8), VariableDetachRewrite.php (Task 8) – implements NodeRewrite
|
||
public function __construct(string $id, string $value, string $variableType); // VariableDetachRewrite; DetachTarget siehe Task 8
|
||
|
||
// RestorePlanner.php (Task 9) – Interface
|
||
public function area(): string; // 'presets' | 'colors' | 'variables'
|
||
public function designArea(): DesignArea;
|
||
public function rights(): ?WP_Error;
|
||
public function features(): array; // list<DiviFeature>
|
||
/** @param array<array-key, mixed> $current @param array<array-key, mixed> $snapshot */
|
||
public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error;
|
||
```
|
||
|
||
## Dateistruktur
|
||
|
||
**Neu (`src/Integrations/Divi/Design/`):** `GlobalColorsAdapter`, `GlobalVariablesAdapter`, `ColorValue`, `ColorBook`, `ColorDiff`, `ColorArea`, `DiviColorsSetAbility`, `VariableValue`, `VariableBook`, `VariableDiff`, `VariableArea`, `DiviVariablesSetAbility`, `ReferenceUsageQuery`, `DetachTarget`, `VariableDetachRewrite`, `RestorePlanner`, `PresetRestorePlanner`, `ColorRestorePlanner`, `VariableRestorePlanner`.
|
||
|
||
**Geändert:** `Release/DiviFeature.php`, `Release/DiviAdapters.php`, `Release/fingerprints.json`, `Design/DesignRights.php`, `Design/PresetBook.php`, `Design/DiviDesignDetachAbility.php`, `Design/DiviDesignRestoreAbility.php`, `Design/DiviDesignSnapshotsAbility.php` (nur Beschreibung, falls nötig), `DiviIntegration.php`, `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` (Nachtrag 5.15.1), `docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md` (Offen-Liste), `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` (§7.2).
|
||
|
||
**Neu (Tests):** je Klasse eine Testdatei; `tests/Integration/divi-colors-variables-smoke.sh`.
|
||
|
||
**Geändert (Tests):** `DiviFeatureTest`, `DiviFeatureAssignmentTest`, `DiviToolFeaturesTest`, `DiviIntegrationTest`, `DiviDesignDetachAbilityTest`, `DiviDesignRestoreAbilityTest`, `DesignRightsTest`, `PresetBookTest` sowie – falls sie Fähigkeiten zählen oder aufzählen – `FeatureOverviewTest`, `FeatureStatusTest`, `DiviTabTest`, `DiviLockedTest`, `ReleaseGateTest`, `ReleaseResolverTest` (Task 1 findet sie mit `grep -rn "PresetWrite\|preset-write" tests`).
|
||
|
||
## Task-Übersicht
|
||
|
||
| # | Task | Ergebnis |
|
||
|---|---|---|
|
||
| 1 | Fähigkeiten `ColorWrite`/`VariableWrite` | Enum, Label, Tests (Commit gemeinsam mit Task 2) |
|
||
| 2 | Adapter und Rechte | `GlobalColorsAdapter`, `GlobalVariablesAdapter`, Manifest, Fingerabdrücke, `DesignRights` |
|
||
| 3 | `ColorValue`, `ColorBook`, `ColorDiff` | reiner Farbbestand |
|
||
| 4 | `VariableValue`, `VariableBook`, `VariableDiff` | reiner Variablenbestand |
|
||
| 5 | `ReferenceUsageQuery`, `PresetBook`-Erweiterungen | Fundstellen und Preset-Verweise |
|
||
| 6 | `ColorArea`, `lit/divi-colors-set` | Farben schreiben |
|
||
| 7 | `VariableArea`, `lit/divi-variables-set` | Variablen schreiben |
|
||
| 8 | `DetachTarget`, Variablen-Detach | `lit/divi-design-detach` mit `variable` |
|
||
| 9 | `RestorePlanner` je Bereich | Restore für Farben und Variablen |
|
||
| 10 | Verdrahtung | `DiviIntegration`, Tests der Fähigkeitszuordnung, Beschreibungen |
|
||
| 11 | Smoke, Notizen, Spec | `divi-colors-variables-smoke.sh`, Formate-Nachtrag, Spec §7.2, Memory-Hinweis |
|
||
| 12 | Gesamtverifikation | Suite, Lint, Smokes, Gesamtreview |
|
||
|
||
---
|
||
|
||
### Task 1: Fähigkeiten `ColorWrite` und `VariableWrite` (V2)
|
||
|
||
Zwei neue Schreib-Fähigkeiten, damit ein Bruch von `set_global_variables` nicht das Farbenschreiben sperrt. Geprüft mit `grep -rn "PresetWrite\|preset-write\|elf Fähigkeiten\|Eleven\|eleven" src tests docs` und einem Probelauf der ganzen Suite mit den zwei neuen Fällen (Stand 63389f3): nur drei Tests brechen – zwei in `DiviFeatureTest` (zählen und listen die Fälle exakt) und `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` (jede Fähigkeit braucht eine Manifest-Anforderung; die kommen erst mit den Adaptern in Task 2). `FeatureOverviewTest`, `FeatureStatusTest`, `DiviTabTest`, `DiviLockedTest`, `ReleaseGateTest`, `ReleaseResolverTest`, `CompatibilityMatrixTest`, `DiviCheckCommandTest` und `DiviEnvironmentComponentTest` rechnen mit `count(DiviFeature::cases())` oder nennen nur einzelne Fähigkeiten und bleiben unverändert grün. Außerhalb der Unit-Tests zählt nur `tests/Integration/divi-release-smoke.sh` die Fähigkeiten (Liste der IDs, „eleven“, `= "11"`). `docs/guides/overview.md` nennt Fähigkeiten nur als Beispiele (bleibt), die Fähigkeiten-Notiz folgt mit Task 11.
|
||
|
||
**Wegen des Zuordnungstests hat Task 1 keinen eigenen Commit:** seine Dateien werden im letzten Schritt von Task 2 zusammen mit den Adaptern committet (siehe `## ABWEICHUNGEN`, A1). Bis dahin ist genau `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` rot.
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/Release/DiviFeature.php` (zwei Fälle, `isWrite()`, `label()`, Docblock von `isWrite()`)
|
||
- Modify: `tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php`
|
||
- Modify: `tests/Integration/divi-release-smoke.sh` (13 statt 11 Fähigkeiten; läuft erst nach Task 2 grün, weil vorher die Signatur-Referenzen der neuen Aufrufe fehlen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DiviFeature` (vorhanden).
|
||
- Produces: `DiviFeature::ColorWrite = 'color-write'` (Label „Farben schreiben“), `DiviFeature::VariableWrite = 'variable-write'` (Label „Variablen schreiben“), beide `isWrite() === true`. Reihenfolge der Fälle (bestimmt die Reihenfolge in Status-Tool, Admin-Reiter und `divi-check`): direkt nach `PresetWrite`.
|
||
|
||
- [ ] **Step 1: `DiviFeatureTest` auf dreizehn Fähigkeiten umstellen**
|
||
|
||
`tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php` vollständig ersetzen:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
|
||
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class DiviFeatureTest extends TestCase
|
||
{
|
||
public function testTheThirteenFeaturesAndTheirValues(): void
|
||
{
|
||
self::assertSame(
|
||
[
|
||
'PageRead' => 'page-read',
|
||
'PageWrite' => 'page-write',
|
||
'Catalog' => 'catalog',
|
||
'DynamicSources' => 'dynamic-sources',
|
||
'DesignRead' => 'design-read',
|
||
'PresetWrite' => 'preset-write',
|
||
'ColorWrite' => 'color-write',
|
||
'VariableWrite' => 'variable-write',
|
||
'LibraryRead' => 'library-read',
|
||
'LibraryWrite' => 'library-write',
|
||
'Roles' => 'roles',
|
||
'ThemeBuilderRead' => 'tb-read',
|
||
'ThemeBuilderWrite' => 'tb-write',
|
||
],
|
||
array_column(array_map(
|
||
static fn (DiviFeature $feature): array => [$feature->name, $feature->value],
|
||
DiviFeature::cases()
|
||
), 1, 0)
|
||
);
|
||
}
|
||
|
||
public function testOnlyTheSixWriteFeaturesWrite(): void
|
||
{
|
||
$writing = array_values(array_map(
|
||
static fn (DiviFeature $feature): string => $feature->value,
|
||
array_filter(DiviFeature::cases(), static fn (DiviFeature $feature): bool => $feature->isWrite())
|
||
));
|
||
|
||
self::assertSame(
|
||
['page-write', 'preset-write', 'color-write', 'variable-write', 'library-write', 'tb-write'],
|
||
$writing
|
||
);
|
||
}
|
||
|
||
public function testEveryFeatureHasAUniqueGermanLabel(): void
|
||
{
|
||
$labels = array_map(static fn (DiviFeature $feature): string => $feature->label(), DiviFeature::cases());
|
||
|
||
self::assertCount(count(DiviFeature::cases()), array_unique($labels));
|
||
self::assertSame('Seiten schreiben', DiviFeature::PageWrite->label());
|
||
self::assertSame('Theme Builder lesen', DiviFeature::ThemeBuilderRead->label());
|
||
self::assertSame('Farben schreiben', DiviFeature::ColorWrite->label());
|
||
self::assertSame('Variablen schreiben', DiviFeature::VariableWrite->label());
|
||
foreach ($labels as $label) {
|
||
self::assertNotSame('', trim($label));
|
||
}
|
||
}
|
||
|
||
public function testValuesResolveBackToTheFeature(): void
|
||
{
|
||
self::assertSame(DiviFeature::ThemeBuilderWrite, DiviFeature::from('tb-write'));
|
||
self::assertNull(DiviFeature::tryFrom('page'));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviFeatureTest`
|
||
Expected: FAIL – `Tests: 4, Assertions: 7, Errors: 1, Failures: 2`:
|
||
- `testEveryFeatureHasAUniqueGermanLabel`: `Error: Undefined constant LIT\MCP\Integrations\Divi\Release\DiviFeature::ColorWrite`
|
||
- `testTheThirteenFeaturesAndTheirValues`: Array-Vergleich, es fehlen `'ColorWrite' => 'color-write'` und `'VariableWrite' => 'variable-write'`
|
||
- `testOnlyTheSixWriteFeaturesWrite`: es fehlen `color-write` und `variable-write`
|
||
|
||
- [ ] **Step 3: Fälle, `isWrite()` und `label()` ergänzen**
|
||
|
||
```diff
|
||
--- a/src/Integrations/Divi/Release/DiviFeature.php
|
||
+++ b/src/Integrations/Divi/Release/DiviFeature.php
|
||
@@ -21,17 +21,20 @@
|
||
case DynamicSources = 'dynamic-sources';
|
||
case DesignRead = 'design-read';
|
||
case PresetWrite = 'preset-write';
|
||
+ case ColorWrite = 'color-write';
|
||
+ case VariableWrite = 'variable-write';
|
||
case LibraryRead = 'library-read';
|
||
case LibraryWrite = 'library-write';
|
||
case Roles = 'roles';
|
||
case ThemeBuilderRead = 'tb-read';
|
||
case ThemeBuilderWrite = 'tb-write';
|
||
|
||
- /** Whether the feature changes data (the four *Write cases). */
|
||
+ /** Whether the feature changes data (the six *Write cases). */
|
||
public function isWrite(): bool
|
||
{
|
||
return match ($this) {
|
||
- self::PageWrite, self::PresetWrite, self::LibraryWrite, self::ThemeBuilderWrite => true,
|
||
+ self::PageWrite, self::PresetWrite, self::ColorWrite, self::VariableWrite, self::LibraryWrite,
|
||
+ self::ThemeBuilderWrite => true,
|
||
default => false,
|
||
};
|
||
}
|
||
@@ -46,6 +49,8 @@
|
||
self::DynamicSources => 'Dynamische Inhalte',
|
||
self::DesignRead => 'Designsystem lesen',
|
||
self::PresetWrite => 'Presets schreiben',
|
||
+ self::ColorWrite => 'Farben schreiben',
|
||
+ self::VariableWrite => 'Variablen schreiben',
|
||
self::LibraryRead => 'Bibliothek lesen',
|
||
self::LibraryWrite => 'Bibliothek schreiben',
|
||
self::Roles => 'Divi-Rollen',
|
||
```
|
||
|
||
- [ ] **Step 4: GREEN**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviFeatureTest`
|
||
Expected: `OK (4 tests, …)`.
|
||
|
||
Run: `composer test`
|
||
Expected: genau ein Fehlschlag, `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` mit `Fähigkeiten ohne Anforderung in einem Manifest` und `+ 0 => 'color-write'` / `+ 1 => 'variable-write'`. Jeder andere Fehlschlag ist ein Befund (ein Test zählt Fähigkeiten doch fest) und wird hier mit angepasst.
|
||
|
||
- [ ] **Step 5: Integrations-Smoke auf dreizehn Fähigkeiten umstellen**
|
||
|
||
```diff
|
||
--- a/tests/Integration/divi-release-smoke.sh
|
||
+++ b/tests/Integration/divi-release-smoke.sh
|
||
@@ -27,8 +27,8 @@
|
||
|
||
check_pages() { wp post list --post_type=page --post_status=any --meta_key=_lit_mcp_check_page --format=count; }
|
||
|
||
-# The ids of the eleven features (plan decision D1), sorted.
|
||
-FEATURE_IDS='["catalog","design-read","dynamic-sources","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write"]'
|
||
+# The ids of the thirteen features (plan decision D1, plan 4c V2), sorted.
|
||
+FEATURE_IDS='["catalog","color-write","design-read","dynamic-sources","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write","variable-write"]'
|
||
|
||
# Adds a problem to one feature in the cached state, as if its mirror check had failed. Needs the cache entry of
|
||
# ReleaseResolver (format 2: ["format" => 2, "key" => stateKey, "problems" => list of Problem::toArray()]); the key
|
||
@@ -68,7 +68,7 @@
|
||
.components[] | select(.id == "divi") | .features
|
||
| ([.[].id] | sort) == $ids
|
||
and all(.state == "released" and .source == "code" and .problems == [])' >/dev/null \
|
||
- || fail "all eleven features must be released from code without problems: $STATUS"
|
||
+ || fail "all thirteen features must be released from code without problems: $STATUS"
|
||
echo "$STATUS" | jq -e '
|
||
.components[] | select(.id == "divi") | .features
|
||
| (.[] | select(.id == "page-write") | .tools | any(. == "lit-divi-insert"))
|
||
@@ -76,7 +76,7 @@
|
||
and (.[] | select(.id == "preset-write") | .tools | any(startswith("lit/divi-presets-")))
|
||
and (.[] | select(.id == "page-read") | .label == "Seiten lesen")' >/dev/null \
|
||
|| fail "features must name their tools (direct lit-<name>, indirect lit/<name>): $STATUS"
|
||
-pass "lit-site-status: eleven features released (code), each with its tools"
|
||
+pass "lit-site-status: thirteen features released (code), each with its tools"
|
||
|
||
# 2. Admin check through the REST route (application password instead of the cookie nonce).
|
||
# X-Forwarded-Proto: https is required here: without it the request to http://localhost with the
|
||
@@ -212,8 +212,8 @@
|
||
CLI=$(wp --user="$ADMIN_LOGIN" lit-mcp divi-check 2>&1) || fail "wp lit-mcp divi-check must succeed (exit 0): $CLI"
|
||
echo "$CLI" | grep -q '^Fähigkeiten:' || fail "divi-check must print the matrix: $CLI"
|
||
echo "$CLI" | grep -q '✓ Seiten schreiben – freigegeben (code)' || fail "divi-check must show page-write released: $CLI"
|
||
-[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "11" ] || fail "divi-check must show eleven released features: $CLI"
|
||
+[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "13" ] || fail "divi-check must show thirteen released features: $CLI"
|
||
[ "$(check_pages)" = "0" ] || fail "the CLI check must delete its page"
|
||
-pass "wp lit-mcp divi-check prints eleven released features, recomputes the state and leaves no page"
|
||
+pass "wp lit-mcp divi-check prints thirteen released features, recomputes the state and leaves no page"
|
||
|
||
finish
|
||
```
|
||
|
||
Run: `bash -n tests/Integration/divi-release-smoke.sh && ! grep -n eleven tests/Integration/divi-release-smoke.sh`
|
||
Expected: keine Ausgabe, Exit 0 (Syntax gültig, kein „eleven“ mehr). Der Smoke selbst läuft in Task 2, Step 12.
|
||
|
||
- [ ] **Step 6: Lint, kein Commit**
|
||
|
||
Run: `composer lint`
|
||
Expected: sauber.
|
||
|
||
Nicht committen: die drei Dateien gehen mit dem Commit von Task 2 (Step 13) ins Repo, weil `composer test` erst mit den Adaptern grün ist.
|
||
|
||
---
|
||
|
||
### Task 2: Adapter `GlobalColorsAdapter`/`GlobalVariablesAdapter`, Manifest, Fingerabdrücke, `DesignRights` (V3, V4, V7)
|
||
|
||
Zwei kleine Adapter im Bereich „Designsystem“ – die einzigen Klassen, die Farben und Variablen über Divi schreiben – mit eigenem Manifest und der Fähigkeit des Bereichs als Vorgabe. Geteilte Divi-Aufrufe stehen wie bei `DesignSystemReader`/`DesignSystemWriter` (`GlobalPreset::get_data`) in **jedem** Manifest, das sie nutzt: `get_global_colors`/`get_global_variables` bleiben im Reader mit `design-read`/`page-read` und stehen hier zusätzlich mit `color-write` bzw. `variable-write`; `et_pb_is_allowed` bleibt in `DiviRoles` mit `roles` und steht hier mit `variable-write`. `DiviAdapters::calls()` führt jedes Symbol nur einmal (für die Signatur egal), `featuresOf()` gilt je Manifest. Kein Aufruf braucht eine eigene Fähigkeitsliste – die Vorgabe des Manifests reicht, weil nur die Schreibwege diese Adapter nutzen (das Designsystem-Tool liest weiter über `DesignSystemReader`).
|
||
|
||
Geprüft an `themes/Divi/includes/builder-5/server/Packages/GlobalData/GlobalData.php` (5.15.1): `get_global_colors(): array` (0 Parameter; Hauptfarben aus `get_customizer_colors()` mit `static $formatted_colors` vor den gespeicherten, `lastUpdated` = jetzt), `sanitize_global_colors_data(array $data): array` (1; verwirft Einträge mit leerem `color` oder ohne `gcid-`-Präfix), `set_global_colors(array $data, $already_sanitized = false): void` (2; schreibt Hauptfarben über `et_update_option` in `et_divi`, mischt bei `true` mit den gespeicherten, ersetzt bei `false`), `get_global_variables(): array` (0; Gruppen als `stdClass`, Einträge mit `allowedActions`, Gruppe `colors`, Customizer-Schriften `--et_global_heading_font`/`--et_global_body_font`), `set_global_variables(array $data, ?array $expected_variables = null): ?WP_Error` (2; ohne `edit_theme_options` oder `et_pb_is_allowed('variables_manager')` still `null`; vergleicht `$expected` **bereinigt** mit dem bereinigten Bestand → `global_variables_stale`, dann `global_variable_ids_missing`, dann `validate_global_variable_changes`), `et_pb_is_allowed($capabilities, $role = '')` in `includes/builder/core.php` (1). `sanitize_global_variables_data()` behält nur `gvid-`-IDs der Typen `numbers|strings|images|links|fonts|gradients` – darum filtert `read()` nach `gvid-` (das entfernt die Customizer-Schriften und jeden Fremdeintrag genau wie Divi) und lässt `colors` weg.
|
||
|
||
**Unit-Tests ohne Divi:** `GlobalData` fehlt in den Unit-Tests. Die Tests von `read()`/`save()` ersetzen die Klasse mit `Mockery::mock('alias:ET\Builder\Packages\GlobalData\GlobalData')` und laufen mit `@runInSeparateProcess` (ein Alias definiert die Klasse für den Rest des Prozesses; Vorbild für die eigene Prozess-Regel: `DiviPagesTest::testIsLinkableFollowsDivisPublicPostTypes`, `DiviThemeBuilderTest::testMissingDiviFunctionsAreRefusedBeforeWriting`). Ebenso der positive Fall von `canManage()` (Brain Monkey definiert `et_pb_is_allowed` sonst prozessweit, `DiviRolesTest`/`DiviIntegrationTest` verlassen sich auf ihr Fehlen). Beide Adapter sind – wie `DesignSystemReader`/`DesignSystemWriter` – **nicht `final`**, damit `DesignRightsTest` und die Bereiche (Tasks 6/7) sie mit Mockery ersetzen können (siehe `## ABWEICHUNGEN`, A2).
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/GlobalColorsAdapter.php`
|
||
- Create: `src/Integrations/Divi/Design/GlobalVariablesAdapter.php`
|
||
- Create: `tests/Unit/Integrations/Divi/Design/GlobalColorsAdapterTest.php`
|
||
- Create: `tests/Unit/Integrations/Divi/Design/GlobalVariablesAdapterTest.php`
|
||
- Modify: `src/Integrations/Divi/Release/DiviAdapters.php` (beide Klassen nach `DesignSystemWriter::class`)
|
||
- Modify: `tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php` (gepinnte Tabelle, „ten adapters“)
|
||
- Modify: `src/Integrations/Divi/Release/fingerprints.json` (generiert, drei neue `sig:`-Schlüssel)
|
||
- Modify: `src/Integrations/Divi/Design/DesignRights.php` (`colors()`, `variables()`, private `designer()`)
|
||
- Modify: `tests/Unit/Integrations/Divi/Design/DesignRightsTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Manifest`, `Call`, `DiviAdapter`, `DiviAdapters::CLASSES`, `ReleaseGate::assertFeature()`, `DiviLocked`, `Errors::forbidden()`, `DesignSystemWriter::canUseVisualBuilder()` (alle vorhanden), `DiviFeature::ColorWrite`/`VariableWrite` (Task 1); Tests: `LIT\MCP\Tests\Unit\TestCase` (`assertToolError`), `ReleaseMatrices::uniform(ReleaseState, string)`, Brain Monkey `Functions\when()`.
|
||
- Produces:
|
||
- `class GlobalColorsAdapter implements DiviAdapter` – `manifest()`: Bereich `Manifest::DESIGN`, Aufrufe `GlobalData::get_global_colors` (0), `GlobalData::sanitize_global_colors_data` (1), `GlobalData::set_global_colors` (2), Schlüssel `et_divi`, Vorgabe `[DiviFeature::ColorWrite]`; `read(): array` = `get_global_colors()`, Hauptfarben mit dem Wert aus `et_divi` überlagert, wenn dort ein nicht leerer String steht (V3; kein Eintrag wird erfunden); `save(array $entries, bool $replace): void` – `ReleaseGate::assertFeature(ColorWrite)`, dann `sanitize_global_colors_data($entries)`; fehlt danach ein Eintrag → `RuntimeException('global colors refused: Divi dropped <IDs>')`, nichts geschrieben; sonst `set_global_colors($clean, ! $replace)` (V4: `false` → mischen, `true` → alles ersetzen).
|
||
- `class GlobalVariablesAdapter implements DiviAdapter` – `manifest()`: Aufrufe `GlobalData::get_global_variables` (0), `GlobalData::set_global_variables` (2), `et_pb_is_allowed` (1), keine Schlüssel, Vorgabe `[DiviFeature::VariableWrite]`; `read(): array` Speicherform (V7); `save(array $data, array $expected): void` – `ReleaseGate::assertFeature(VariableWrite)`, `set_global_variables($data, $expected)`, `WP_Error` → `RuntimeException('global variables refused: <code> <message>')`; `canManage(): bool` = `function_exists('et_pb_is_allowed') && et_pb_is_allowed('variables_manager')`.
|
||
- `DesignRights::colors(DesignSystemWriter $writer): ?WP_Error` – dieselben Rechte wie `presets()`, Meldungen mit „Farben“.
|
||
- `DesignRights::variables(DesignSystemWriter $writer, GlobalVariablesAdapter $variables): ?WP_Error` – erst wie `colors()` (Meldungen mit „Variablen“), dann `canManage()`, sonst `forbidden` „… erlaubt den Variablen-Manager nicht …“ mit Hinweis auf den Rollen-Editor.
|
||
- `presets()` verhält sich unverändert (gleiche Texte, jetzt über `designer($writer, 'Presets')`).
|
||
|
||
- [ ] **Step 1: Failing Test `GlobalColorsAdapterTest` schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Design/GlobalColorsAdapterTest.php` anlegen:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||
use LIT\MCP\Tests\Support\ReleaseMatrices;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* Divi's GlobalData is replaced by a Mockery alias; the tests that need it run in their own process (an alias
|
||
* defines the class for the rest of the process).
|
||
*/
|
||
final class GlobalColorsAdapterTest extends TestCase
|
||
{
|
||
private const GLOBAL_DATA = 'ET\\Builder\\Packages\\GlobalData\\GlobalData';
|
||
|
||
public function testIsARegisteredDesignAdapterForColorWrite(): void
|
||
{
|
||
$manifest = GlobalColorsAdapter::manifest();
|
||
|
||
self::assertContains(GlobalColorsAdapter::class, DiviAdapters::CLASSES);
|
||
self::assertSame(Manifest::DESIGN, $manifest->area);
|
||
self::assertSame(
|
||
['GlobalData::get_global_colors()', 'GlobalData::sanitize_global_colors_data()',
|
||
'GlobalData::set_global_colors()'],
|
||
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
|
||
);
|
||
self::assertSame([0, 1, 2], array_map(static fn (Call $call): ?int => $call->args, $manifest->calls));
|
||
self::assertSame(['et_divi'], $manifest->keys);
|
||
self::assertSame([DiviFeature::ColorWrite], $manifest->features);
|
||
}
|
||
|
||
public function testWritesNeedAReleasedDivi(): void
|
||
{
|
||
ReleaseGate::useResolver(
|
||
static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0')
|
||
);
|
||
|
||
$this->expectException(DiviLocked::class);
|
||
|
||
(new GlobalColorsAdapter())->save(['gcid-abc' => ['color' => '#fff']], false);
|
||
}
|
||
|
||
/** @runInSeparateProcess */
|
||
public function testReadLaysTheStoredCustomizerValuesOverDivisCache(): void
|
||
{
|
||
Mockery::mock('alias:' . self::GLOBAL_DATA)->allows('get_global_colors')->andReturn([
|
||
'gcid-primary-color' => ['color' => '#2ea3f2', 'folder' => 'customizer', 'status' => 'active'],
|
||
'gcid-secondary-color' => ['color' => '#2ea3f2', 'folder' => 'customizer', 'status' => 'active'],
|
||
'gcid-body-color' => ['color' => '#666666', 'folder' => 'customizer', 'status' => 'active'],
|
||
'gcid-abc1234567' => ['color' => '#123456', 'folder' => '', 'status' => 'active'],
|
||
]);
|
||
Functions\when('get_option')->alias(static fn (string $name): mixed => $name === 'et_divi'
|
||
? ['accent_color' => '#ff0000', 'secondary_accent_color' => '', 'font_color' => 7, 'link_color' => '#00f']
|
||
: false);
|
||
|
||
$colors = (new GlobalColorsAdapter())->read();
|
||
|
||
self::assertSame('#ff0000', $colors['gcid-primary-color']['color'], 'stored option wins over the cache');
|
||
self::assertSame('#2ea3f2', $colors['gcid-secondary-color']['color'], 'an empty option is not laid over');
|
||
self::assertSame('#666666', $colors['gcid-body-color']['color'], 'a non-string option is not laid over');
|
||
self::assertArrayNotHasKey('gcid-link-color', $colors, 'no entry is invented');
|
||
self::assertSame('#123456', $colors['gcid-abc1234567']['color']);
|
||
}
|
||
|
||
/** @runInSeparateProcess */
|
||
public function testReadWithoutThemeOptionsIsDivisAnswer(): void
|
||
{
|
||
$divi = ['gcid-primary-color' => ['color' => '#2ea3f2', 'folder' => 'customizer']];
|
||
Mockery::mock('alias:' . self::GLOBAL_DATA)->allows('get_global_colors')->andReturn($divi);
|
||
Functions\when('get_option')->justReturn(false);
|
||
|
||
self::assertSame($divi, (new GlobalColorsAdapter())->read());
|
||
}
|
||
|
||
/** @runInSeparateProcess */
|
||
public function testSaveSanitizesAndMergesOrReplaces(): void
|
||
{
|
||
$entries = ['gcid-abc1234567' => ['color' => '#123456', 'status' => 'active', 'usedInPosts' => []]];
|
||
$clean = ['gcid-abc1234567' => ['color' => '#123456', 'status' => 'active', 'usedInPosts' => []]];
|
||
$divi = Mockery::mock('alias:' . self::GLOBAL_DATA);
|
||
$divi->expects('sanitize_global_colors_data')->twice()->with($entries)->andReturn($clean);
|
||
// Normal write: merge ("already sanitized" = true); restore: replace all (false).
|
||
$divi->expects('set_global_colors')->once()->with($clean, true)->ordered();
|
||
$divi->expects('set_global_colors')->once()->with($clean, false)->ordered();
|
||
|
||
(new GlobalColorsAdapter())->save($entries, false);
|
||
(new GlobalColorsAdapter())->save($entries, true);
|
||
}
|
||
|
||
/** @runInSeparateProcess */
|
||
public function testSaveRefusesWhenDiviDropsAnEntry(): void
|
||
{
|
||
$divi = Mockery::mock('alias:' . self::GLOBAL_DATA);
|
||
$divi->allows('sanitize_global_colors_data')->andReturn(['gcid-abc1234567' => ['color' => '#123456']]);
|
||
$divi->expects('set_global_colors')->never();
|
||
|
||
$this->expectException(RuntimeException::class);
|
||
$this->expectExceptionMessage('global colors refused: Divi dropped gcid-empty00000');
|
||
|
||
(new GlobalColorsAdapter())->save([
|
||
'gcid-abc1234567' => ['color' => '#123456'],
|
||
'gcid-empty00000' => ['color' => ''],
|
||
], false);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Failing Test `GlobalVariablesAdapterTest` schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Design/GlobalVariablesAdapterTest.php` anlegen:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||
use LIT\MCP\Tests\Support\ReleaseMatrices;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use RuntimeException;
|
||
use WP_Error;
|
||
|
||
/**
|
||
* Divi's GlobalData is replaced by a Mockery alias and et_pb_is_allowed() by Brain Monkey; those tests run in their
|
||
* own process (both define a symbol for the rest of the process).
|
||
*/
|
||
final class GlobalVariablesAdapterTest extends TestCase
|
||
{
|
||
private const GLOBAL_DATA = 'ET\\Builder\\Packages\\GlobalData\\GlobalData';
|
||
|
||
public function testIsARegisteredDesignAdapterForVariableWrite(): void
|
||
{
|
||
$manifest = GlobalVariablesAdapter::manifest();
|
||
|
||
self::assertContains(GlobalVariablesAdapter::class, DiviAdapters::CLASSES);
|
||
self::assertSame(Manifest::DESIGN, $manifest->area);
|
||
self::assertSame(
|
||
['GlobalData::get_global_variables()', 'GlobalData::set_global_variables()', 'et_pb_is_allowed()'],
|
||
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
|
||
);
|
||
self::assertSame([0, 2, 1], array_map(static fn (Call $call): ?int => $call->args, $manifest->calls));
|
||
self::assertSame([], $manifest->keys);
|
||
self::assertSame([DiviFeature::VariableWrite], $manifest->features);
|
||
}
|
||
|
||
public function testWritesNeedAReleasedDivi(): void
|
||
{
|
||
ReleaseGate::useResolver(
|
||
static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0')
|
||
);
|
||
|
||
$this->expectException(DiviLocked::class);
|
||
|
||
(new GlobalVariablesAdapter())->save([], []);
|
||
}
|
||
|
||
public function testWithoutDiviNobodyManagesVariables(): void
|
||
{
|
||
// Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed".
|
||
self::assertFalse((new GlobalVariablesAdapter())->canManage());
|
||
}
|
||
|
||
/** @runInSeparateProcess */
|
||
public function testCanManageAsksTheVariablesManagerRight(): void
|
||
{
|
||
Functions\when('et_pb_is_allowed')->alias(static fn (string $cap): bool => $cap === 'variables_manager');
|
||
|
||
self::assertTrue((new GlobalVariablesAdapter())->canManage());
|
||
}
|
||
|
||
/** @runInSeparateProcess */
|
||
public function testReadReturnsTheStoredForm(): void
|
||
{
|
||
$actions = ['allowedActions' => ['editLabel' => true, 'editValue' => true, 'remove' => true]];
|
||
$space = ['label' => 'Abstand', 'value' => '24px', 'order' => '1', 'status' => 'active'];
|
||
$wave = ['label' => 'Welle', 'value' => 'linear-gradient(#fff,#000)', 'order' => '1', 'status' => 'active'];
|
||
Mockery::mock('alias:' . self::GLOBAL_DATA)->allows('get_global_variables')->andReturn([
|
||
'numbers' => (object) ['gvid-space00001' => $space + $actions],
|
||
'strings' => (object) [],
|
||
'colors' => (object) ['gcid-x' => ['label' => 'X'] + $actions],
|
||
'fonts' => (object) [
|
||
'--et_global_heading_font' => ['label' => 'Überschrift', 'value' => 'Open Sans'] + $actions,
|
||
'gvid-font000001' => (object) (['label' => 'Fließtext', 'value' => 'Inter'] + $actions),
|
||
],
|
||
'gradients' => (object) ['gvid-wave000001' => $wave + $actions],
|
||
]);
|
||
|
||
self::assertSame(
|
||
[
|
||
'numbers' => ['gvid-space00001' => $space],
|
||
'fonts' => ['gvid-font000001' => ['label' => 'Fließtext', 'value' => 'Inter']],
|
||
'gradients' => ['gvid-wave000001' => $wave],
|
||
],
|
||
(new GlobalVariablesAdapter())->read()
|
||
);
|
||
}
|
||
|
||
/** @runInSeparateProcess */
|
||
public function testSavePassesThePlannedInventoryAndThrowsOnARefusal(): void
|
||
{
|
||
$data = ['numbers' => ['gvid-space00001' => ['label' => 'Abstand', 'value' => '32px']]];
|
||
$expected = ['numbers' => ['gvid-space00001' => ['label' => 'Abstand', 'value' => '24px']]];
|
||
Mockery::mock('alias:' . self::GLOBAL_DATA)->expects('set_global_variables')->twice()->with($data, $expected)
|
||
->andReturn(null, new WP_Error('global_variables_stale', 'Global variables changed.'));
|
||
|
||
(new GlobalVariablesAdapter())->save($data, $expected);
|
||
|
||
$this->expectException(RuntimeException::class);
|
||
$this->expectExceptionMessage('global variables refused: global_variables_stale Global variables changed.');
|
||
(new GlobalVariablesAdapter())->save($data, $expected);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Gepinnte Tabelle in `DiviFeatureAssignmentTest` erweitern**
|
||
|
||
```diff
|
||
--- a/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php
|
||
+++ b/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php
|
||
@@ -6,6 +6,8 @@
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
+use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
|
||
+use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
|
||
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
|
||
@@ -70,6 +72,16 @@
|
||
'UserRole::can_current_user_use_visual_builder()' => ['preset-write'],
|
||
'Nachbau GlobalPresetController::sync()' => ['preset-write'],
|
||
],
|
||
+ GlobalColorsAdapter::class => [
|
||
+ 'GlobalData::get_global_colors()' => ['color-write'],
|
||
+ 'GlobalData::sanitize_global_colors_data()' => ['color-write'],
|
||
+ 'GlobalData::set_global_colors()' => ['color-write'],
|
||
+ ],
|
||
+ GlobalVariablesAdapter::class => [
|
||
+ 'GlobalData::get_global_variables()' => ['variable-write'],
|
||
+ 'GlobalData::set_global_variables()' => ['variable-write'],
|
||
+ 'et_pb_is_allowed()' => ['variable-write'],
|
||
+ ],
|
||
DiviModules::class => [
|
||
'ModuleRegistration::get_all_core_modules_metadata()' => ['catalog'],
|
||
'ModuleRegistration::get_default_attrs()' => ['catalog'],
|
||
@@ -131,7 +143,7 @@
|
||
}
|
||
|
||
self::assertSame(self::EXPECTED, $actual);
|
||
- self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all eight adapters, in report order');
|
||
+ self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all ten adapters, in report order');
|
||
}
|
||
|
||
public function testReadingAdaptersCarryNoWriteFeature(): void
|
||
```
|
||
|
||
- [ ] **Step 4: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'GlobalColorsAdapterTest|GlobalVariablesAdapterTest|DiviFeatureAssignmentTest'`
|
||
Expected: FAIL – `Tests: 17, Assertions: 24, Errors: 9, Failures: 5`: neun Mal `Error: Class "LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter" not found` bzw. `…GlobalVariablesAdapter" not found`, drei Mal `Failed asserting that exception of type "Error" matches expected exception …` (gleiche Ursache), dazu `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` (aus Task 1) und `::testTheAssignmentIsThePinnedTable`.
|
||
|
||
- [ ] **Step 5: `GlobalColorsAdapter` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Design/GlobalColorsAdapter.php` anlegen:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use ET\Builder\Packages\GlobalData\GlobalData;
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use RuntimeException;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Adapter "Designsystem" (spec §3) for the global colors (formats §1, plan 4c V3/V4): reads them through
|
||
* GlobalData::get_global_colors() and writes them through set_global_colors(). The five customizer colors live in
|
||
* Divi's theme options; get_customizer_colors() caches them in a static variable for the whole request, so read()
|
||
* lays the stored option values over Divi's answer – a read-back after writing sees the new value. Not final so the
|
||
* color area can be tested with a mock.
|
||
*/
|
||
class GlobalColorsAdapter implements DiviAdapter
|
||
{
|
||
/** Divi's theme options (one array option) with the customizer colors. */
|
||
private const OPTIONS = 'et_divi';
|
||
|
||
/** Customizer color ID => its option in et_divi (GlobalData::$customizer_colors). */
|
||
private const CUSTOMIZER = [
|
||
'gcid-primary-color' => 'accent_color',
|
||
'gcid-secondary-color' => 'secondary_accent_color',
|
||
'gcid-heading-color' => 'header_color',
|
||
'gcid-body-color' => 'font_color',
|
||
'gcid-link-color' => 'link_color',
|
||
];
|
||
|
||
public static function manifest(): Manifest
|
||
{
|
||
return new Manifest(
|
||
Manifest::DESIGN,
|
||
calls: [
|
||
new Call(GlobalData::class . '::get_global_colors', 0),
|
||
new Call(GlobalData::class . '::sanitize_global_colors_data', 1),
|
||
new Call(GlobalData::class . '::set_global_colors', 2),
|
||
],
|
||
keys: [self::OPTIONS],
|
||
features: [DiviFeature::ColorWrite]
|
||
);
|
||
}
|
||
|
||
/** @return array<string, array<string, mixed>> get_global_colors() with the customizer values of et_divi. */
|
||
public function read(): array
|
||
{
|
||
$colors = GlobalData::get_global_colors();
|
||
$options = get_option(self::OPTIONS);
|
||
foreach (self::CUSTOMIZER as $id => $option) {
|
||
$value = is_array($options) ? ($options[$option] ?? null) : null;
|
||
if (is_array($colors[$id] ?? null) && is_string($value) && $value !== '') {
|
||
$colors[$id]['color'] = $value;
|
||
}
|
||
}
|
||
|
||
return $colors;
|
||
}
|
||
|
||
/**
|
||
* Saves checked entries: $replace false merges them into the stored colors (set_global_colors with
|
||
* "already sanitized"), $replace true replaces all stored colors (restore). Customizer colors among them go to
|
||
* the theme options (Divi does that itself).
|
||
*
|
||
* @param array<string, array<string, mixed>> $entries
|
||
* @throws DiviLocked When color-write is not released – before anything is written.
|
||
* @throws RuntimeException When Divi's sanitizing drops an entry; nothing is written then.
|
||
*/
|
||
public function save(array $entries, bool $replace): void
|
||
{
|
||
ReleaseGate::assertFeature(DiviFeature::ColorWrite);
|
||
$clean = GlobalData::sanitize_global_colors_data($entries);
|
||
$dropped = array_diff(array_keys($entries), array_keys($clean));
|
||
if ($dropped !== []) {
|
||
throw new RuntimeException('global colors refused: Divi dropped ' . implode(', ', $dropped));
|
||
}
|
||
|
||
GlobalData::set_global_colors($clean, ! $replace);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: `GlobalVariablesAdapter` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Design/GlobalVariablesAdapter.php` anlegen:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use ET\Builder\Packages\GlobalData\GlobalData;
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use RuntimeException;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Adapter "Designsystem" (spec §3) for the global variables (formats §2, plan 4c V7): reads them through
|
||
* GlobalData::get_global_variables() back into the stored form and writes them through set_global_variables() with
|
||
* the inventory the change was planned on (Divi's own stale check). Without edit_theme_options or Divi's role right
|
||
* "Variables Manager" Divi silently saves nothing – DesignRights::variables() asks canManage() first. Not final so
|
||
* the variable area can be tested with a mock.
|
||
*/
|
||
class GlobalVariablesAdapter implements DiviAdapter
|
||
{
|
||
public static function manifest(): Manifest
|
||
{
|
||
return new Manifest(
|
||
Manifest::DESIGN,
|
||
calls: [
|
||
new Call(GlobalData::class . '::get_global_variables', 0),
|
||
new Call(GlobalData::class . '::set_global_variables', 2),
|
||
new Call('et_pb_is_allowed', 1),
|
||
],
|
||
features: [DiviFeature::VariableWrite]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The stored form: type => ID => entry as arrays, without allowedActions, without the customizer fonts and the
|
||
* group "colors" (both are not stored there), empty groups left out.
|
||
*
|
||
* @return array<string, array<string, array<string, mixed>>>
|
||
*/
|
||
public function read(): array
|
||
{
|
||
$data = [];
|
||
foreach (GlobalData::get_global_variables() as $type => $items) {
|
||
if ($type === 'colors' || (! is_array($items) && ! is_object($items))) {
|
||
continue;
|
||
}
|
||
foreach ((array) $items as $id => $item) {
|
||
// Divi's sanitizing keeps gvid- IDs only; the customizer fonts (--et_global_*_font) are not stored.
|
||
if (! str_starts_with((string) $id, 'gvid-') || (! is_array($item) && ! is_object($item))) {
|
||
continue;
|
||
}
|
||
$entry = (array) $item;
|
||
unset($entry['allowedActions']);
|
||
$data[(string) $type][(string) $id] = $entry;
|
||
}
|
||
}
|
||
|
||
return $data;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, array<string, array<string, mixed>>> $data The whole new inventory (stored form).
|
||
* @param array<string, array<string, array<string, mixed>>> $expected The inventory the change was planned on.
|
||
* @throws DiviLocked When variable-write is not released – before anything is written.
|
||
* @throws RuntimeException When Divi refuses the inventory (stale, missing IDs, invalid entries).
|
||
*/
|
||
public function save(array $data, array $expected): void
|
||
{
|
||
ReleaseGate::assertFeature(DiviFeature::VariableWrite);
|
||
$refusal = GlobalData::set_global_variables($data, $expected);
|
||
if ($refusal instanceof WP_Error) {
|
||
throw new RuntimeException('global variables refused: ' . $refusal->get_error_code() . ' '
|
||
. $refusal->get_error_message());
|
||
}
|
||
}
|
||
|
||
/** Divi's role right "Variables Manager"; fail closed without Divi. */
|
||
public function canManage(): bool
|
||
{
|
||
return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed('variables_manager');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Adapter in `DiviAdapters::CLASSES` aufnehmen**
|
||
|
||
```diff
|
||
--- a/src/Integrations/Divi/Release/DiviAdapters.php
|
||
+++ b/src/Integrations/Divi/Release/DiviAdapters.php
|
||
@@ -6,6 +6,8 @@
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
+use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
|
||
+use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
|
||
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
|
||
@@ -27,6 +29,8 @@
|
||
DiviThemeBuilder::class,
|
||
DesignSystemReader::class,
|
||
DesignSystemWriter::class,
|
||
+ GlobalColorsAdapter::class,
|
||
+ GlobalVariablesAdapter::class,
|
||
DiviModules::class,
|
||
DynamicContentMap::class,
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 8: GREEN (ohne Fingerabdrücke)**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'GlobalColorsAdapterTest|GlobalVariablesAdapterTest|DiviFeatureAssignmentTest|DiviBoundaryTest|DiviFeatureTest'`
|
||
Expected: `OK` (u. a. `DiviBoundaryTest::testAdaptersDeclareExactlyTheDiviNamesTheyUse`: die Adapter nutzen genau `ET\Builder\Packages\GlobalData\GlobalData`, `et_divi` bzw. `et_pb_is_allowed` – `'variables_manager'`, `'gvid-'` und der Kommentar `--et_global_*_font` sind für `DiviTouches` keine Divi-Namen).
|
||
|
||
Run: `composer test`
|
||
Expected: genau ein Fehlschlag, `DiviReferenceTest::testEveryMirrorAndEveryCallHasAnExpectedFingerprintAndEveryMirrorAReferenceSource` – in `fingerprints.json` fehlen `sig:ET\Builder\Packages\GlobalData\GlobalData::sanitize_global_colors_data`, `…::set_global_colors` und `…::set_global_variables` (Step 11).
|
||
|
||
- [ ] **Step 9: Failing Tests für `DesignRights::colors()`/`variables()` schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Design/DesignRightsTest.php` vollständig ersetzen (der bisherige Test bleibt unverändert als erster):
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignRights;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class DesignRightsTest extends TestCase
|
||
{
|
||
public function testPresetsNeedEditThemeOptionsAndTheVisualBuilder(): void
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true, false);
|
||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
|
||
|
||
self::assertNull(DesignRights::presets($writer));
|
||
$refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden');
|
||
self::assertStringContainsString('Visual Builder', $refusal['message']);
|
||
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
$refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden');
|
||
self::assertStringContainsString('edit_theme_options', $refusal['message']);
|
||
}
|
||
|
||
public function testColorsNeedEditThemeOptionsAndTheVisualBuilder(): void
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true, false);
|
||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
|
||
|
||
self::assertNull(DesignRights::colors($writer));
|
||
$refusal = self::assertToolError(DesignRights::colors($writer), 'forbidden');
|
||
self::assertStringContainsString('keine Farben', $refusal['message']);
|
||
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
$refusal = self::assertToolError(DesignRights::colors($writer), 'forbidden');
|
||
self::assertStringContainsString('Farben ändern erfordert', $refusal['message']);
|
||
self::assertStringContainsString('edit_theme_options', $refusal['message']);
|
||
}
|
||
|
||
public function testVariablesAlsoNeedTheVariablesManager(): void
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$variables = Mockery::mock(GlobalVariablesAdapter::class);
|
||
$variables->allows('canManage')->andReturn(true, false);
|
||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
|
||
|
||
self::assertNull(DesignRights::variables($writer, $variables));
|
||
$refusal = self::assertToolError(DesignRights::variables($writer, $variables), 'forbidden');
|
||
self::assertStringContainsString('Variablen-Manager', $refusal['message']);
|
||
self::assertStringContainsString('Rollen-Editor', $refusal['hint']);
|
||
}
|
||
|
||
public function testVariablesAskTheThemeRightAndTheVisualBuilderFirst(): void
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(false);
|
||
$variables = Mockery::mock(GlobalVariablesAdapter::class);
|
||
$variables->allows('canManage')->andReturn(true);
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
|
||
$refusal = self::assertToolError(DesignRights::variables($writer, $variables), 'forbidden');
|
||
self::assertStringContainsString('keine Variablen', $refusal['message']);
|
||
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
$refusal = self::assertToolError(DesignRights::variables($writer, $variables), 'forbidden');
|
||
self::assertStringContainsString('Variablen ändern erfordert', $refusal['message']);
|
||
}
|
||
}
|
||
```
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DesignRightsTest`
|
||
Expected: FAIL – `Tests: 4, Assertions: 9, Errors: 3`: `Error: Call to undefined method LIT\MCP\Integrations\Divi\Design\DesignRights::colors()` (1×) und `…::variables()` (2×).
|
||
|
||
- [ ] **Step 10: `DesignRights` erweitern**
|
||
|
||
`src/Integrations/Divi/Design/DesignRights.php` vollständig ersetzen (`unfilteredHtml()` und `markup()` unverändert; `presets()` delegiert an `designer()` mit denselben Texten wie bisher):
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Who may change the design system (spec §10): presets and colors need edit_theme_options and Divi's role right
|
||
* "Visual Builder verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1"); variables
|
||
* also the role right "Variables Manager". Without unfiltered_html a write is refused while presets it saves along
|
||
* hold HTML/CSS that Divi would strip (final review I-2).
|
||
*/
|
||
final class DesignRights
|
||
{
|
||
public const OTHER_MARKUP = 'Andere Presets enthalten HTML/CSS, das beim Speichern ohne das Recht unfiltered_html '
|
||
. 'verloren ginge; nichts wurde gespeichert.';
|
||
|
||
/** @return Closure(): bool Whether the current user may save unfiltered HTML. */
|
||
public static function unfilteredHtml(): Closure
|
||
{
|
||
return static fn (): bool => current_user_can('unfiltered_html');
|
||
}
|
||
|
||
/**
|
||
* Refuses the write when the user lacks unfiltered_html and $markupIds (PresetBook::markupOutside()) is not empty.
|
||
*
|
||
* @param Closure(): bool $unfiltered
|
||
* @param list<string> $markupIds
|
||
*/
|
||
public static function markup(
|
||
Closure $unfiltered,
|
||
array $markupIds,
|
||
string $message = self::OTHER_MARKUP
|
||
): ?WP_Error {
|
||
if ($markupIds === [] || $unfiltered()) {
|
||
return null;
|
||
}
|
||
|
||
return Errors::forbidden(
|
||
$message,
|
||
'Einen Administrator (mit dem Recht unfiltered_html) die Änderung vornehmen lassen.'
|
||
);
|
||
}
|
||
|
||
public static function presets(DesignSystemWriter $writer): ?WP_Error
|
||
{
|
||
return self::designer($writer, 'Presets');
|
||
}
|
||
|
||
/** Colors: the same rights as presets (spec §10 – stricter than Divi, which asks edit_theme_options only). */
|
||
public static function colors(DesignSystemWriter $writer): ?WP_Error
|
||
{
|
||
return self::designer($writer, 'Farben');
|
||
}
|
||
|
||
/** Variables: also Divi's role right "Variables Manager" – without it Divi silently saves nothing (plan 4c V7). */
|
||
public static function variables(DesignSystemWriter $writer, GlobalVariablesAdapter $variables): ?WP_Error
|
||
{
|
||
$refusal = self::designer($writer, 'Variablen');
|
||
if ($refusal !== null || $variables->canManage()) {
|
||
return $refusal;
|
||
}
|
||
|
||
return Errors::forbidden(
|
||
'Die Divi-Rolle dieses Kontos erlaubt den Variablen-Manager nicht; ohne ihn speichert Divi keine '
|
||
. 'Variablen.',
|
||
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
|
||
);
|
||
}
|
||
|
||
/** edit_theme_options and Divi's role right "Visual Builder verwenden"; $what names the area in the message. */
|
||
private static function designer(DesignSystemWriter $writer, string $what): ?WP_Error
|
||
{
|
||
if (! current_user_can('edit_theme_options')) {
|
||
return Errors::forbidden(sprintf(
|
||
'%s ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).',
|
||
$what
|
||
));
|
||
}
|
||
if (! $writer->canUseVisualBuilder()) {
|
||
return Errors::forbidden(
|
||
sprintf(
|
||
'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine %s.',
|
||
$what
|
||
),
|
||
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DesignRightsTest|DiviPresets|DiviDesign'`
|
||
Expected: `OK` (die Preset-Tools sehen dieselben Meldungen wie vorher).
|
||
|
||
- [ ] **Step 11: Signatur-Referenzen in DDEV erzeugen**
|
||
|
||
`wp lit-mcp divi-check --update-reference` (`src/Integrations/Divi/Cli/DiviCheckCommand.php`) verlangt einen Administrator (`manage_options`, der Schreibtest legt eine Wegwerfseite an), schreibt nur, wenn außer „Fingerabdrücke“ alle Prüfschritte grün sind (Manifeste: die neuen Aufrufe existieren mit passender Argumentzahl; die fehlende Signatur-Referenz ist kein Manifest-Fehler, nur ein `reference-missing`-Problem), und nur in einer Entwicklungskopie mit `tests/` (`DiviReference::sourceDir()`). Es schreibt die Referenzquellen der Nachbauten (unverändert, keine neuen Mirrors) und `fingerprints.json` (Nachbauten + `sig:` jedes Aufrufs aus `DiviAdapters::calls()`).
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress
|
||
ddev start
|
||
ddev exec bash -c 'wp --user="$(wp user list --role=administrator --field=user_login | head -1)" lit-mcp divi-check --update-reference'
|
||
```
|
||
Expected: die Prüfschritte mit `✓` (`Fingerabdrücke nachgebauter Divi-Funktionen` darf `✓` oder `✗` sein), dann `Referenz aktualisiert (6 Fingerabdrücke, 32 Signaturen). Smoke-Skripte laufen lassen, dann committen.` und die Fähigkeiten-Matrix; Exit-Code 0. Bricht WP-CLI mit Monolog-/Deprecation-Ausgaben ab, wie für die Smokes `postlos` und `asynclistry` vorher deaktivieren (`ddev wp plugin deactivate postlos asynclistry`) und danach wieder aktivieren (`ddev wp plugin activate postlos asynclistry`).
|
||
|
||
Ergebnis prüfen:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
|
||
git diff -U0 src/Integrations/Divi/Release/fingerprints.json | grep '^[-+] '
|
||
git status --short tests/Fixtures/divi/reference
|
||
jq -r 'keys[]' src/Integrations/Divi/Release/fingerprints.json | grep -c '^sig:'
|
||
```
|
||
Expected: genau drei `+`-Zeilen und keine `-`-Zeile – `"sig:ET\\Builder\\Packages\\GlobalData\\GlobalData::sanitize_global_colors_data": "<sha256>"`, `"…GlobalData::set_global_colors": "<sha256>"`, `"…GlobalData::set_global_variables": "<sha256>"`; keine Änderung unter `tests/Fixtures/divi/reference`; `32`. Ändert sich ein anderer Schlüssel, **nicht committen**, sondern prüfen (installiertes Divi ≠ 5.15.1 oder eine Divi-Signatur hat sich geändert).
|
||
|
||
- [ ] **Step 12: Gesamtlauf, Lint, Freigabe-Smoke**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK (alle Tests grün, auch `DiviReferenceTest` und `DiviFeatureAssignmentTest`), Lint sauber.
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh`
|
||
Expected: `ALL CHECKS PASSED` – u. a. `ok - manifests and fingerprints match the installed Divi` (keine `reference-missing`-Probleme für die drei neuen Aufrufe) und `ok - lit-site-status: thirteen features released (code), each with its tools`. Läuft nur mit deaktivierten Plugins `postlos` und `asynclistry`.
|
||
|
||
- [ ] **Step 13: Commit (Tasks 1 und 2)**
|
||
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
|
||
git add src/Integrations/Divi/Release/DiviFeature.php tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php \
|
||
tests/Integration/divi-release-smoke.sh \
|
||
src/Integrations/Divi/Design/GlobalColorsAdapter.php src/Integrations/Divi/Design/GlobalVariablesAdapter.php \
|
||
tests/Unit/Integrations/Divi/Design/GlobalColorsAdapterTest.php \
|
||
tests/Unit/Integrations/Divi/Design/GlobalVariablesAdapterTest.php \
|
||
src/Integrations/Divi/Release/DiviAdapters.php tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php \
|
||
src/Integrations/Divi/Release/fingerprints.json \
|
||
src/Integrations/Divi/Design/DesignRights.php tests/Unit/Integrations/Divi/Design/DesignRightsTest.php
|
||
git commit -m "feat: Fähigkeiten color-write/variable-write; GlobalColorsAdapter und GlobalVariablesAdapter (Manifest, Signatur-Referenzen) und DesignRights::colors()/variables()" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### Task 3: `ColorValue`, `ColorBook`, `ColorDiff` – reiner Farbbestand (V3, V5)
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/ColorValue.php`, `ColorBook.php`, `ColorDiff.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/ColorValueTest.php`, `ColorBookTest.php`, `ColorDiffTest.php` (alle neu, erben von `PHPUnit\Framework\TestCase`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LIT\MCP\Abilities\Rev::of(string): string` (sha256, 12 Hex-Zeichen – dasselbe Verfahren wie `PresetBook::rev()`); `LIT\MCP\Integrations\Divi\Compose\VariableRef::serialize()`, `::typeOf()`, `::MARKER`, `::END` (nur öffentliche, reine Mitglieder; `serialize()` nur in den Tests); `DesignSystem::COLOR_ALIASES` (öffentliche Konstante: `primary` → `gcid-primary-color` …).
|
||
- Produces (Signaturen wie im Schnittstellenvertrag, keine Abweichung):
|
||
- `ColorValue::check(string): ?string`, `ColorValue::relative(array): array|string`, `ColorValue::TYPES_NOTE`.
|
||
- `ColorBook`: `fromDivi`, `data`, `rev`, `entry`, `label`, `isCustomizer`, `find`, `with`, `referencing`, `cycle`, `changedSince`, `newId`, `now`, `CUSTOMIZER_IDS`. Die Testdatei `ColorBookTest` liefert die Hilfen `ColorBookTest::stored()` (Form von `get_global_colors()`: zwei Hauptfarben, „Holz dunkel“, eine relative Farbe „Holz hell“, zwei Farben mit Label „Doppelt“) und `ColorBookTest::ref($id, $settings)`; spätere Tests (`ColorAreaTest`, `ColorRestorePlannerTest`) dürfen sie benutzen.
|
||
- `ColorDiff::between(ColorBook, ColorBook): list<array{id, name, action, fields?}>`, `ColorDiff::summary(array): string`.
|
||
|
||
**Regeln dieses Tasks (alle durch Tests belegt):**
|
||
- `ColorValue::check()` prüft nur Klartext-Werte (V5): Hex mit 3, 4, 6 oder 8 Stellen, `rgb()`/`rgba()`/`hsl()`/`hsla()` (nur Ziffern, Buchstaben, `. % + - / ,` und Leerraum, mindestens eine Ziffer, höchstens 100 Zeichen – nichts, womit man aus dem Wert ausbrechen könnte), ein Schlüsselwort aus 3–20 Buchstaben. Leerraum am Rand, `$variable(`, `{{` und alles andere gelten als ungültig; der Aufrufer löst Platzhalter vorher auf und trimmt Eingaben. `relative()` erlaubt nur `hue` (−360…360), `saturation`, `lightness`, `opacity` (je −100…100), nur int/float (kein Text, kein Bool, kein NaN), liefert sie in fester Reihenfolge und macht ganze Gleitkommazahlen zu `int`.
|
||
- `ColorBook` kennt die sechs Schlüssel `color, folder, label, lastUpdated, status, usedInPosts`; fehlende werden aufgefüllt (`''`, `''`, `''`, `''`, `'active'`, `[]`), fremde fallen weg. Eingabe als Array **oder** Objekt (auch verschachtelt `stdClass`) – `get_global_colors()` und ein Snapshot haben dieselbe Form. `lastUpdated` der Hauptfarben (ID in `CUSTOMIZER_IDS` oder `folder: customizer`) ist immer `''` (V3), auch nach `with()`.
|
||
- `find()`: ID, dann Alias (`primary` …), dann Optionsname (`accent_color` …), dann Label (getrimmt, ohne Beachtung der Groß-/Kleinschreibung); mehrere Treffer → `InvalidArgumentException` „Name der globalen Farbe "X" ist mehrdeutig; bitte die ID angeben: a, b.“ (Text wie `DesignSystem::byLabel`).
|
||
- `cycle()` folgt je Farbe der Kette ihrer Verweise (`VariableRef::typeOf()` = `color`, Ziel aus dem Payload) und meldet die erste Kette mit Schleife (`[a, b, c, b]`, Selbstverweis `[a, a]`) oder mit mehr als 10 Verweisen (12 IDs bei 11 Verweisen; Divi löst mit `$depth >= 10` nur bis zum 10. Verweis auf). Ein Verweis auf eine unbekannte Farbe beendet die Kette ohne Fund – das prüft `ColorArea` (Task 6).
|
||
- `ColorDiff`: Verglichen werden `color`, `folder`, `label`, `status` (nicht `lastUpdated`, `usedInPosts`). Statuswechsel nach `inactive` = `deactivated`, nach `active` = `activated`; jeder andere Statuswechsel (`temporary`) = `changed` mit `status` in `fields`. `fields` bei `deactivated`/`activated` nennt nur weitere Änderungen. Fehlt eine Farbe nachher, ist das `removed` (nur ein Restore mit Ersetzen kann das).
|
||
|
||
#### Task 3a: `ColorValue` – Farbwerte und relative Einstellungen prüfen
|
||
|
||
- [ ] **Step 3a.1: Failing Test `ColorValueTest` schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Design/ColorValueTest.php`. Datenanbieter für gültige und ungültige Werte je Form, Grenzen der relativen Einstellungen inklusive.
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\ColorValue;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class ColorValueTest extends TestCase
|
||
{
|
||
/** @return array<string, array{string}> */
|
||
public static function validColors(): array
|
||
{
|
||
return [
|
||
'hex3' => ['#abc'], 'hex4' => ['#abcd'], 'hex6' => ['#5B3A29'], 'hex8' => ['#5b3a29cc'],
|
||
'rgb' => ['rgb(10, 20, 30)'], 'rgba' => ['rgba(10,20,30,0.5)'], 'rgb modern' => ['rgb(10 20 30 / 50%)'],
|
||
'hsl' => ['hsl(210, 50%, 40%)'], 'hsla' => ['hsla(210deg, 50%, 40%, .5)'],
|
||
'keyword' => ['red'], 'keyword upper' => ['Transparent'], 'keyword long' => ['lightgoldenrodyellow'],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider validColors */
|
||
public function testValidColors(string $color): void
|
||
{
|
||
self::assertNull(ColorValue::check($color));
|
||
}
|
||
|
||
/** @return array<string, array{string}> */
|
||
public static function invalidColors(): array
|
||
{
|
||
return [
|
||
'empty' => [''], 'hex5' => ['#abcde'], 'hex7' => ['#abcdef1'], 'hex without hash' => ['abcdef12'],
|
||
'hex digits' => ['#ggg'], 'surrounding space' => [' #fff'], 'function without number' => ['rgb()'],
|
||
'function with var' => ['rgb(var(--x))'], 'function with semicolon' => ['rgb(1;2;3)'],
|
||
'unknown function' => ['url(1)'], 'keyword too short' => ['re'], 'keyword with digit' => ['red1'],
|
||
'keyword too long' => ['abcdefghijklmnopqrstu'], 'css injection' => ['red;}body{x:y'],
|
||
'too long' => ['rgb(' . str_repeat('1,', 60) . '1)'],
|
||
'reference' => ['$variable({"type":"color","value":{"name":"gcid-a","settings":{}}})$'],
|
||
'placeholder' => ['{{color:primary}}'],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider invalidColors */
|
||
public function testInvalidColorsGetAGermanMessage(string $color): void
|
||
{
|
||
$message = ColorValue::check($color);
|
||
|
||
self::assertIsString($message);
|
||
self::assertNotSame('', $message);
|
||
}
|
||
|
||
public function testMessageNamesTheAllowedForms(): void
|
||
{
|
||
self::assertStringContainsString(ColorValue::TYPES_NOTE, (string) ColorValue::check('#abcde'));
|
||
self::assertStringContainsString('Farbschlüsselwort', ColorValue::TYPES_NOTE);
|
||
self::assertStringContainsString('Platzhalter', (string) ColorValue::check('{{color:primary}}'));
|
||
}
|
||
|
||
public function testRelativeKeepsTheOrderAndNormalizesWholeFloats(): void
|
||
{
|
||
self::assertSame(
|
||
['hue' => 10, 'lightness' => -12.5, 'opacity' => 100],
|
||
ColorValue::relative(['opacity' => 100.0, 'lightness' => -12.5, 'hue' => 10])
|
||
);
|
||
self::assertSame(['saturation' => 0], ColorValue::relative(['saturation' => 0]));
|
||
}
|
||
|
||
public function testRelativeBoundsAreInclusive(): void
|
||
{
|
||
self::assertSame(['hue' => -360], ColorValue::relative(['hue' => -360]));
|
||
self::assertSame(['hue' => 360], ColorValue::relative(['hue' => 360]));
|
||
self::assertSame(['opacity' => 100], ColorValue::relative(['opacity' => 100]));
|
||
self::assertSame(['opacity' => -100], ColorValue::relative(['opacity' => -100]));
|
||
}
|
||
|
||
public function testRelativeRejectsRangesTypesAndUnknownKeys(): void
|
||
{
|
||
self::assertSame('relative.hue: Zahl zwischen -360 und 360 erwartet.', ColorValue::relative(['hue' => 361]));
|
||
self::assertSame(
|
||
'relative.lightness: Zahl zwischen -100 und 100 erwartet.',
|
||
ColorValue::relative(['lightness' => -101])
|
||
);
|
||
self::assertSame(
|
||
'relative.saturation: Zahl zwischen -100 und 100 erwartet.',
|
||
ColorValue::relative(['saturation' => '10'])
|
||
);
|
||
self::assertSame(
|
||
'relative.opacity: Zahl zwischen -100 und 100 erwartet.',
|
||
ColorValue::relative(['opacity' => true])
|
||
);
|
||
self::assertSame(
|
||
'relative.hue: Zahl zwischen -360 und 360 erwartet.',
|
||
ColorValue::relative(['hue' => NAN])
|
||
);
|
||
self::assertSame(
|
||
'relative: unbekannte Einstellung "alpha"; erlaubt: hue, saturation, lightness, opacity.',
|
||
ColorValue::relative(['alpha' => 1])
|
||
);
|
||
self::assertSame(
|
||
'relative: mindestens eine Einstellung nötig (hue, saturation, lightness, opacity).',
|
||
ColorValue::relative([])
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3a.2: RED-Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorValueTest`
|
||
Expected: Abbruch mit `Error: Class "LIT\MCP\Integrations\Divi\Design\ColorValue" not found`.
|
||
|
||
- [ ] **Step 3a.3: `ColorValue` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Design/ColorValue.php`. Reines PHP; nur Regexe und Vergleiche.
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Checks the value of a global color before it goes to Divi (ruling V5, D7 principle): hex, rgb()/rgba()/hsl()/hsla(),
|
||
* or a color keyword. Placeholders ({{color:…}}) and $variable(…)$ references are no plain values – they are resolved
|
||
* against the planned inventory by the caller; relative() checks the "relative" settings that belong to them.
|
||
* Pure PHP.
|
||
*/
|
||
final class ColorValue
|
||
{
|
||
public const TYPES_NOTE = 'Erlaubt sind Hex-Werte (#rgb, #rgba, #rrggbb, #rrggbbaa), rgb()/rgba()/hsl()/hsla(), '
|
||
. 'ein Farbschlüsselwort (z. B. red) oder ein Platzhalter {{color:<Name>}} zusammen mit "relative".';
|
||
|
||
/**
|
||
* shortcut: these ranges are the sliders of the Visual Builder, not derived from Divi's PHP code; widen them if a
|
||
* Divi release accepts more.
|
||
*/
|
||
private const RANGES = [
|
||
'hue' => [-360, 360],
|
||
'saturation' => [-100, 100],
|
||
'lightness' => [-100, 100],
|
||
'opacity' => [-100, 100],
|
||
];
|
||
|
||
private const MAX_LENGTH = 100;
|
||
|
||
private const HEX = '/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/iD';
|
||
|
||
/** Only characters a color function needs; no "(", ")", ";" or quotes inside – nothing to break out of. */
|
||
private const FUNCTION = '/^(?:rgb|rgba|hsl|hsla)\(\s*[0-9a-z.%+\-\/,\s]*\)$/iD';
|
||
|
||
private const KEYWORD = '/^[a-z]{3,20}$/iD';
|
||
|
||
/** @return string|null Null when $color is a valid plain color value, else a German message. */
|
||
public static function check(string $color): ?string
|
||
{
|
||
if ($color === '') {
|
||
return 'Farbwert fehlt. ' . self::TYPES_NOTE;
|
||
}
|
||
if (str_contains($color, '$variable(') || str_contains($color, '{{')) {
|
||
return 'Verweise ($variable(…)$) und Platzhalter sind hier kein Farbwert; Platzhalter {{color:<Name>}} '
|
||
. 'gehören allein in "color" und brauchen "relative".';
|
||
}
|
||
|
||
$valid = strlen($color) <= self::MAX_LENGTH && (
|
||
preg_match(self::HEX, $color) === 1
|
||
|| preg_match(self::KEYWORD, $color) === 1
|
||
|| (preg_match(self::FUNCTION, $color) === 1 && preg_match('/\d/', $color) === 1)
|
||
);
|
||
|
||
return $valid ? null : sprintf('Ungültiger Farbwert "%s". %s', mb_substr($color, 0, 60), self::TYPES_NOTE);
|
||
}
|
||
|
||
/**
|
||
* The settings for VariableRef::serialize('color', …): only hue, saturation, lightness, opacity, numbers within
|
||
* their ranges, in that order; whole floats become ints.
|
||
*
|
||
* @param array<string, mixed> $relative
|
||
* @return array<string, int|float>|string The settings, or a German message.
|
||
*/
|
||
public static function relative(array $relative): array|string
|
||
{
|
||
if ($relative === []) {
|
||
return 'relative: mindestens eine Einstellung nötig (' . implode(', ', array_keys(self::RANGES)) . ').';
|
||
}
|
||
|
||
$unknown = array_diff(array_map('strval', array_keys($relative)), array_keys(self::RANGES));
|
||
if ($unknown !== []) {
|
||
return sprintf(
|
||
'relative: unbekannte Einstellung "%s"; erlaubt: %s.',
|
||
implode('", "', $unknown),
|
||
implode(', ', array_keys(self::RANGES))
|
||
);
|
||
}
|
||
|
||
$settings = [];
|
||
foreach (self::RANGES as $key => [$min, $max]) {
|
||
if (! array_key_exists($key, $relative)) {
|
||
continue;
|
||
}
|
||
$value = $relative[$key];
|
||
$number = is_int($value) || is_float($value);
|
||
if (! $number || ! is_finite((float) $value) || $value < $min || $value > $max) {
|
||
return sprintf('relative.%s: Zahl zwischen %d und %d erwartet.', $key, $min, $max);
|
||
}
|
||
$settings[$key] = is_float($value) && floor($value) === $value ? (int) $value : $value;
|
||
}
|
||
|
||
return $settings;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3a.4: GREEN-Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorValueTest`
|
||
Expected: `OK (33 tests, 62 assertions)`.
|
||
|
||
- [ ] **Step 3a.5: Lint**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldung (PSR-12 + WPCS, Zeilen ≤ 120 Zeichen).
|
||
|
||
- [ ] **Step 3a.6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/ColorValue.php tests/Unit/Integrations/Divi/Design/ColorValueTest.php
|
||
git commit -m "feat: ColorValue prüft Farbwerte (Hex, rgb/hsl, Schlüsselwort) und die relativen Einstellungen von Farbverweisen" \
|
||
-m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 3b: `ColorBook` – unveränderlicher Farbbestand
|
||
|
||
- [ ] **Step 3b.1: Failing Test `ColorBookTest` schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Design/ColorBookTest.php`. Deckt ab: Auffüllen fehlender Schlüssel, Objekte als Eingabe, Normalisierung von `lastUpdated` der Hauptfarben und stabile `rev()` (Reihenfolge egal), die echte Divi-Fixture `tests/Fixtures/divi/stage4/global-colors.json` (`stored` = Snapshot-Form, `get_global_colors` = Lese-Form), `isCustomizer`, `find` nach ID/Alias/Optionsname/Label inklusive Mehrdeutigkeit, `with`, `referencing`, `cycle` (Schleife, Selbstverweis, Tiefe 10 ok, Tiefe 11 Fehler, unbekanntes Ziel), `changedSince`, `newId`, `now`.
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Design\ColorBook;
|
||
use PHPUnit\Framework\TestCase;
|
||
use stdClass;
|
||
|
||
final class ColorBookTest extends TestCase
|
||
{
|
||
/** @return array<string, array<string, mixed>> The shape of GlobalData::get_global_colors() (formats §1). */
|
||
public static function stored(string $customizerStamp = '2026-10-19T08:00:00.000Z'): array
|
||
{
|
||
$entry = static fn (
|
||
string $color,
|
||
string $label,
|
||
string $folder = '',
|
||
string $stamp = '2026-09-27T22:21:39.000Z',
|
||
string $status = 'active'
|
||
): array => [
|
||
'color' => $color, 'folder' => $folder, 'label' => $label, 'lastUpdated' => $stamp,
|
||
'status' => $status, 'usedInPosts' => [],
|
||
];
|
||
|
||
return [
|
||
'gcid-primary-color' => $entry('#2ea3f2', 'Primärfarbe', 'customizer', $customizerStamp),
|
||
'gcid-secondary-color' => $entry('#2ea3f2', 'Sekundärfarbe', 'customizer', $customizerStamp),
|
||
'gcid-q9swzy97df' => $entry('#5b3a29', 'Holz dunkel'),
|
||
'gcid-989stx15zc' => $entry(self::ref('gcid-q9swzy97df', ['lightness' => 30]), 'Holz hell'),
|
||
'gcid-aaaaaaaaaa' => $entry('#111111', 'Doppelt'),
|
||
'gcid-bbbbbbbbbb' => $entry('#222222', 'doppelt', '', '2026-09-27T22:21:39.000Z', 'inactive'),
|
||
];
|
||
}
|
||
|
||
/** @param array<string, int|float> $settings */
|
||
public static function ref(string $id, array $settings = []): string
|
||
{
|
||
return VariableRef::serialize('color', $id, $settings);
|
||
}
|
||
|
||
public function testMissingKeysAreFilledAndOnlyTheSixKeysStay(): void
|
||
{
|
||
$book = ColorBook::fromDivi([
|
||
'gcid-x1' => ['color' => '#fff', 'extra' => 'dropped'],
|
||
'gcid-x2' => (object) ['color' => '#000', 'label' => 'Schwarz', 'usedInPosts' => [4 => 12]],
|
||
'gcid-x3' => 'not an entry',
|
||
]);
|
||
|
||
self::assertSame(['gcid-x1', 'gcid-x2'], array_keys($book->data()));
|
||
self::assertSame([
|
||
'color' => '#fff', 'folder' => '', 'label' => '', 'lastUpdated' => '', 'status' => 'active',
|
||
'usedInPosts' => [],
|
||
], $book->entry('gcid-x1'));
|
||
self::assertSame([12], $book->entry('gcid-x2')['usedInPosts']);
|
||
self::assertSame('Schwarz', $book->label('gcid-x2'));
|
||
self::assertSame('gcid-x1', $book->label('gcid-x1'), 'a color without a label shows its ID');
|
||
self::assertNull($book->entry('gcid-nope'));
|
||
}
|
||
|
||
public function testObjectInputIsAcceptedLikeAnArray(): void
|
||
{
|
||
$raw = new stdClass();
|
||
$raw->{'gcid-x1'} = (object) ['color' => '#fff', 'status' => 'inactive'];
|
||
|
||
self::assertSame(ColorBook::fromDivi(['gcid-x1' => ['color' => '#fff', 'status' => 'inactive']])->data(),
|
||
ColorBook::fromDivi($raw)->data());
|
||
}
|
||
|
||
public function testCustomizerLastUpdatedIsNormalizedSoRevStaysStable(): void
|
||
{
|
||
$now = ColorBook::fromDivi(self::stored('2026-10-19T08:00:00.000Z'));
|
||
$later = ColorBook::fromDivi(self::stored('2026-10-19T09:30:00.000Z'));
|
||
|
||
self::assertSame('', $now->entry('gcid-primary-color')['lastUpdated']);
|
||
self::assertSame('2026-09-27T22:21:39.000Z', $now->entry('gcid-q9swzy97df')['lastUpdated']);
|
||
self::assertSame($now->rev(), $later->rev());
|
||
self::assertSame($now->data(), $later->data());
|
||
self::assertSame([], $later->changedSince($now));
|
||
}
|
||
|
||
public function testRevIgnoresTheOrderOfEntriesAndChangesWithTheContent(): void
|
||
{
|
||
$stored = self::stored();
|
||
$book = ColorBook::fromDivi($stored);
|
||
$reversed = ColorBook::fromDivi(array_reverse($stored, true));
|
||
|
||
self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', $book->rev());
|
||
self::assertSame($book->rev(), $reversed->rev());
|
||
self::assertSame($book->rev(), ColorBook::fromDivi($stored)->rev());
|
||
self::assertNotSame($book->rev(), $book->with('gcid-q9swzy97df', ['color' => '#000000'])->rev());
|
||
}
|
||
|
||
public function testRealDiviFixtureAndTheSnapshotOfItAreTheSame(): void
|
||
{
|
||
$json = (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/stage4/global-colors.json');
|
||
$fixture = json_decode($json, true);
|
||
$read = ColorBook::fromDivi($fixture['get_global_colors']);
|
||
$snapshot = ColorBook::fromDivi($read->data());
|
||
|
||
self::assertCount(9, $read->data());
|
||
self::assertSame($read->rev(), $snapshot->rev());
|
||
self::assertTrue($read->isCustomizer('gcid-link-color'));
|
||
self::assertFalse($read->isCustomizer('gcid-q9swzy97df'));
|
||
self::assertSame(['gcid-989stx15zc'], $read->referencing('gcid-q9swzy97df'));
|
||
self::assertNull($read->cycle());
|
||
}
|
||
|
||
public function testIsCustomizerByIdOrFolder(): void
|
||
{
|
||
$book = ColorBook::fromDivi([
|
||
'gcid-body-color' => ['color' => '#666'],
|
||
'gcid-legacy' => ['color' => '#666', 'folder' => 'customizer'],
|
||
'gcid-x1' => ['color' => '#666'],
|
||
]);
|
||
|
||
self::assertTrue($book->isCustomizer('gcid-body-color'));
|
||
self::assertTrue($book->isCustomizer('gcid-legacy'));
|
||
self::assertFalse($book->isCustomizer('gcid-x1'));
|
||
self::assertTrue($book->isCustomizer('gcid-heading-color'), 'a customizer ID counts even if it is missing');
|
||
self::assertSame('', $book->entry('gcid-legacy')['lastUpdated']);
|
||
}
|
||
|
||
public function testFindByIdAliasOptionNameAndLabel(): void
|
||
{
|
||
$book = ColorBook::fromDivi(self::stored());
|
||
|
||
self::assertSame('gcid-q9swzy97df', $book->find('gcid-q9swzy97df'));
|
||
self::assertSame('gcid-primary-color', $book->find('primary'));
|
||
self::assertSame('gcid-primary-color', $book->find('PRIMARY'));
|
||
self::assertSame('gcid-secondary-color', $book->find('secondary_accent_color'));
|
||
self::assertSame('gcid-q9swzy97df', $book->find(' holz DUNKEL '));
|
||
self::assertSame('gcid-primary-color', $book->find('Primärfarbe'));
|
||
self::assertNull($book->find('heading'), 'an alias whose color does not exist finds nothing');
|
||
self::assertNull($book->find('Fehlt'));
|
||
self::assertNull($book->find(''));
|
||
}
|
||
|
||
public function testAmbiguousLabelListsTheIds(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage(
|
||
'Name der globalen Farbe "Doppelt" ist mehrdeutig; bitte die ID angeben: gcid-aaaaaaaaaa, gcid-bbbbbbbbbb.'
|
||
);
|
||
|
||
ColorBook::fromDivi(self::stored())->find('Doppelt');
|
||
}
|
||
|
||
public function testWithAddsOrReplacesAndLeavesTheOriginalAlone(): void
|
||
{
|
||
$book = ColorBook::fromDivi(self::stored());
|
||
$added = $book->with('gcid-new0000001', ['color' => '#abc', 'label' => 'Neu']);
|
||
$replaced = $book->with('gcid-q9swzy97df', ['color' => '#000000', 'label' => 'Holz', 'status' => 'inactive']);
|
||
|
||
self::assertCount(6, $book->data());
|
||
self::assertCount(7, $added->data());
|
||
self::assertSame('active', $added->entry('gcid-new0000001')['status']);
|
||
self::assertSame('inactive', $replaced->entry('gcid-q9swzy97df')['status']);
|
||
self::assertSame('#5b3a29', $book->entry('gcid-q9swzy97df')['color']);
|
||
self::assertSame('', $book->with('gcid-primary-color', ['color' => '#fff', 'lastUpdated' => 'x'])
|
||
->entry('gcid-primary-color')['lastUpdated']);
|
||
}
|
||
|
||
public function testReferencingFindsRelativeColorsIncludingSelfReferences(): void
|
||
{
|
||
$book = ColorBook::fromDivi(self::stored())
|
||
->with('gcid-self000001', ['color' => self::ref('gcid-self000001')])
|
||
->with('gcid-text000001', ['color' => 'Hinweis: gcid-q9swzy97df']);
|
||
|
||
self::assertSame(['gcid-989stx15zc'], $book->referencing('gcid-q9swzy97df'));
|
||
self::assertSame(['gcid-self000001'], $book->referencing('gcid-self000001'));
|
||
self::assertSame([], $book->referencing('gcid-989stx15zc'));
|
||
}
|
||
|
||
public function testCycleFindsLoopsAndSelfReferences(): void
|
||
{
|
||
$loop = ColorBook::fromDivi([
|
||
'gcid-a' => ['color' => self::ref('gcid-b')],
|
||
'gcid-b' => ['color' => self::ref('gcid-c', ['hue' => 10])],
|
||
'gcid-c' => ['color' => self::ref('gcid-b')],
|
||
]);
|
||
$self = ColorBook::fromDivi(['gcid-a' => ['color' => self::ref('gcid-a')]]);
|
||
|
||
self::assertSame(['gcid-a', 'gcid-b', 'gcid-c', 'gcid-b'], $loop->cycle());
|
||
self::assertSame(['gcid-a', 'gcid-a'], $self->cycle());
|
||
self::assertNull(ColorBook::fromDivi(self::stored())->cycle());
|
||
}
|
||
|
||
public function testCycleStopsAtMoreThanTenReferences(): void
|
||
{
|
||
$chain = static function (int $references): ColorBook {
|
||
$raw = [];
|
||
for ($i = 0; $i < $references; ++$i) {
|
||
$raw['gcid-c' . $i] = ['color' => self::ref('gcid-c' . ($i + 1))];
|
||
}
|
||
$raw['gcid-c' . $references] = ['color' => '#123456'];
|
||
|
||
return ColorBook::fromDivi($raw);
|
||
};
|
||
|
||
self::assertNull($chain(10)->cycle(), 'ten references still resolve in Divi');
|
||
$tooDeep = $chain(11)->cycle();
|
||
self::assertCount(12, $tooDeep);
|
||
self::assertSame(['gcid-c0', 'gcid-c1'], array_slice($tooDeep, 0, 2));
|
||
}
|
||
|
||
public function testReferenceToAnUnknownColorIsNoCycle(): void
|
||
{
|
||
self::assertNull(ColorBook::fromDivi(['gcid-a' => ['color' => self::ref('gcid-missing')]])->cycle());
|
||
}
|
||
|
||
public function testChangedSinceListsNewAndChangedEntriesOnly(): void
|
||
{
|
||
$before = ColorBook::fromDivi(self::stored());
|
||
$after = $before
|
||
->with('gcid-q9swzy97df', ['color' => '#000000', 'label' => 'Holz dunkel', 'lastUpdated' => 'x'])
|
||
->with('gcid-new0000001', ['color' => '#abc', 'label' => 'Neu'])
|
||
->with('gcid-aaaaaaaaaa', $before->entry('gcid-aaaaaaaaaa') ?? []);
|
||
|
||
self::assertSame(['gcid-q9swzy97df', 'gcid-new0000001'], array_keys($after->changedSince($before)));
|
||
self::assertSame([], $before->changedSince($before));
|
||
self::assertSame(['gcid-primary-color'], array_keys(
|
||
$before->with('gcid-primary-color', ['color' => '#ff0000', 'folder' => 'customizer'])->changedSince($before)
|
||
));
|
||
}
|
||
|
||
public function testNewIdHasTheBuilderFormat(): void
|
||
{
|
||
self::assertMatchesRegularExpression('/^gcid-[0-9a-z]{10}$/', ColorBook::newId());
|
||
self::assertNotSame(ColorBook::newId(), ColorBook::newId());
|
||
self::assertSame(
|
||
'gcid-abcdefghij',
|
||
ColorBook::newId(static fn (int $length): string => substr('abcdefghijkl', 0, $length))
|
||
);
|
||
}
|
||
|
||
public function testNowHasDiviTimestampFormat(): void
|
||
{
|
||
self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', ColorBook::now());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3b.2: RED-Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorBookTest`
|
||
Expected: Abbruch mit `Error: Class "LIT\MCP\Integrations\Divi\Design\ColorBook" not found`.
|
||
|
||
- [ ] **Step 3b.3: `ColorBook` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Design/ColorBook.php`. Reines PHP. `rev()` hängt `colors` + NUL an den kanonischen JSON (Schlüssel rekursiv sortiert) und nutzt `Rev::of()` wie `PresetBook::rev()`. Das Ziel eines Farbverweises liest die Klasse selbst aus dem Payload (`VariableRef::typeOf()` prüft vorher, dass der ganze Wert genau ein Farbverweis ist), weil `VariableRef` nur für Inhaltsverweise eine öffentliche Namens-Lesefunktion (`nameOf`) hat.
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use Closure;
|
||
use DateTimeImmutable;
|
||
use DateTimeZone;
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The global colors in Divi's format (GlobalData::get_global_colors(), formats §1): id => {color, folder, label,
|
||
* lastUpdated, status, usedInPosts}. Takes the shape of get_global_colors() and of a snapshot alike (the same
|
||
* entries), fills missing keys and keeps only these six. The five customizer colors carry lastUpdated "" – Divi
|
||
* creates it fresh on every read, so design_rev and snapshots stay stable (ruling V3). Immutable – with() returns a
|
||
* new book. rev() is the design_rev of the area "colors". Pure PHP.
|
||
*/
|
||
final class ColorBook
|
||
{
|
||
/** Customizer color ID => option name in et_divi (GlobalData::$customizer_colors). */
|
||
public const CUSTOMIZER_IDS = [
|
||
'gcid-primary-color' => 'accent_color',
|
||
'gcid-secondary-color' => 'secondary_accent_color',
|
||
'gcid-heading-color' => 'header_color',
|
||
'gcid-body-color' => 'font_color',
|
||
'gcid-link-color' => 'link_color',
|
||
];
|
||
|
||
/** A color that refers to another one resolves at most this many references deep (Divi: depth 10). */
|
||
private const MAX_DEPTH = 10;
|
||
|
||
private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||
|
||
/** @param array<string, array{color: string, folder: string, label: string, lastUpdated: string,
|
||
* status: string, usedInPosts: list<mixed>}> $data */
|
||
private function __construct(private array $data)
|
||
{
|
||
}
|
||
|
||
/** @param array<array-key, mixed>|object $raw get_global_colors() or a snapshot – entries may be objects. */
|
||
public static function fromDivi(array|object $raw): self
|
||
{
|
||
$data = [];
|
||
foreach (self::plain($raw) as $id => $entry) {
|
||
if (is_array($entry)) {
|
||
$data[(string) $id] = self::normalize((string) $id, $entry);
|
||
}
|
||
}
|
||
|
||
return new self($data);
|
||
}
|
||
|
||
/** @return array<string, array{color: string, folder: string, label: string, lastUpdated: string,
|
||
* status: string, usedInPosts: list<mixed>}> */
|
||
public function data(): array
|
||
{
|
||
return $this->data;
|
||
}
|
||
|
||
/** Hash of the canonical JSON (keys sorted recursively), so the order of the entries does not matter. */
|
||
public function rev(): string
|
||
{
|
||
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
|
||
$json = (string) json_encode(self::sorted($this->data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
|
||
return Rev::of('colors' . "\0" . $json);
|
||
}
|
||
|
||
/** @return array{color: string, folder: string, label: string, lastUpdated: string, status: string,
|
||
* usedInPosts: list<mixed>}|null */
|
||
public function entry(string $id): ?array
|
||
{
|
||
return $this->data[$id] ?? null;
|
||
}
|
||
|
||
/** The label of $id, else $id itself. */
|
||
public function label(string $id): string
|
||
{
|
||
$label = $this->data[$id]['label'] ?? '';
|
||
|
||
return $label === '' ? $id : $label;
|
||
}
|
||
|
||
public function isCustomizer(string $id): bool
|
||
{
|
||
return isset(self::CUSTOMIZER_IDS[$id]) || ($this->data[$id]['folder'] ?? '') === 'customizer';
|
||
}
|
||
|
||
/**
|
||
* The ID of the color $needle names: an ID, else an alias (primary, secondary, heading, body, link), else the
|
||
* option name of a customizer color (accent_color …), else the one color of that label (case-insensitive).
|
||
*
|
||
* @throws InvalidArgumentException When several colors share the label (ruling S13a).
|
||
*/
|
||
public function find(string $needle): ?string
|
||
{
|
||
if (isset($this->data[$needle])) {
|
||
return $needle;
|
||
}
|
||
|
||
$key = mb_strtolower(trim($needle));
|
||
$alias = DesignSystem::COLOR_ALIASES[$key] ?? array_search($key, self::CUSTOMIZER_IDS, true);
|
||
if (is_string($alias) && isset($this->data[$alias])) {
|
||
return $alias;
|
||
}
|
||
|
||
$ids = [];
|
||
foreach ($this->data as $id => $entry) {
|
||
if ($key !== '' && mb_strtolower(trim($entry['label'])) === $key) {
|
||
$ids[] = $id;
|
||
}
|
||
}
|
||
if (count($ids) > 1) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
'Name der globalen Farbe "%s" ist mehrdeutig; bitte die ID angeben: %s.',
|
||
trim($needle),
|
||
implode(', ', $ids)
|
||
));
|
||
}
|
||
|
||
return $ids[0] ?? null;
|
||
}
|
||
|
||
/** @param array<string, mixed> $entry Added or replaced; missing keys are filled like fromDivi() does. */
|
||
public function with(string $id, array $entry): self
|
||
{
|
||
$data = $this->data;
|
||
$data[$id] = self::normalize($id, $entry);
|
||
|
||
return new self($data);
|
||
}
|
||
|
||
/** @return list<string> IDs of the colors whose value is a reference to $id (relative colors). */
|
||
public function referencing(string $id): array
|
||
{
|
||
return array_keys(array_filter(
|
||
$this->data,
|
||
static fn (array $entry): bool => self::targetOf($entry['color']) === $id
|
||
));
|
||
}
|
||
|
||
/**
|
||
* The first chain of references that loops or is deeper than Divi resolves (more than 10 references), as the
|
||
* IDs from its start to its end (a loop ends with the ID it began to repeat); null when there is none.
|
||
*
|
||
* @return list<string>|null
|
||
*/
|
||
public function cycle(): ?array
|
||
{
|
||
foreach (array_keys($this->data) as $start) {
|
||
$chain = [$start];
|
||
$id = $start;
|
||
while (isset($this->data[$id]) && ($next = self::targetOf($this->data[$id]['color'])) !== null) {
|
||
$chain[] = $next;
|
||
if (in_array($next, array_slice($chain, 0, -1), true) || count($chain) - 1 > self::MAX_DEPTH) {
|
||
return $chain;
|
||
}
|
||
$id = $next;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* New and changed entries compared to $before – what the normal write path hands to Divi (ruling V4). The
|
||
* customizer colors' lastUpdated is "" in both books and never counts.
|
||
*
|
||
* @return array<string, array{color: string, folder: string, label: string, lastUpdated: string,
|
||
* status: string, usedInPosts: list<mixed>}>
|
||
*/
|
||
public function changedSince(self $before): array
|
||
{
|
||
return array_filter(
|
||
$this->data,
|
||
static fn (array $entry, string $id): bool => $before->entry($id) !== $entry,
|
||
ARRAY_FILTER_USE_BOTH
|
||
);
|
||
}
|
||
|
||
/** A new color ID like the Visual Builder's: "gcid-" and 10 characters of [0-9a-z] (formats §1). */
|
||
public static function newId(?Closure $random = null): string
|
||
{
|
||
$random ??= static function (int $length): string {
|
||
$id = '';
|
||
for ($i = 0; $i < $length; ++$i) {
|
||
$id .= self::ALPHABET[random_int(0, 35)];
|
||
}
|
||
|
||
return $id;
|
||
};
|
||
|
||
return 'gcid-' . $random(10);
|
||
}
|
||
|
||
/** lastUpdated of a color written now: Y-m-d\TH:i:s.v\Z in UTC (formats §1). */
|
||
public static function now(): string
|
||
{
|
||
return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.v\Z');
|
||
}
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $entry
|
||
* @return array{color: string, folder: string, label: string, lastUpdated: string, status: string,
|
||
* usedInPosts: list<mixed>}
|
||
*/
|
||
private static function normalize(string $id, array $entry): array
|
||
{
|
||
$text = static fn (string $key, string $default): string => is_scalar($entry[$key] ?? null)
|
||
? (string) $entry[$key]
|
||
: $default;
|
||
$folder = $text('folder', '');
|
||
|
||
return [
|
||
'color' => $text('color', ''),
|
||
'folder' => $folder,
|
||
'label' => $text('label', ''),
|
||
'lastUpdated' => isset(self::CUSTOMIZER_IDS[$id]) || $folder === 'customizer'
|
||
? ''
|
||
: $text('lastUpdated', ''),
|
||
'status' => $text('status', 'active'),
|
||
'usedInPosts' => is_array($entry['usedInPosts'] ?? null) ? array_values($entry['usedInPosts']) : [],
|
||
];
|
||
}
|
||
|
||
/** The color ID $color refers to when it is exactly one $variable(…)$ color reference, else null. */
|
||
private static function targetOf(string $color): ?string
|
||
{
|
||
if (VariableRef::typeOf($color) !== 'color') {
|
||
return null;
|
||
}
|
||
// typeOf() proved a whole reference: strip marker and end like Divi does, the JSON object is in between.
|
||
$payload = json_decode(substr($color, strlen(VariableRef::MARKER), -strlen(VariableRef::END)), true);
|
||
$name = is_array($payload) ? ($payload['value']['name'] ?? null) : null;
|
||
|
||
return is_string($name) ? $name : null;
|
||
}
|
||
|
||
private static function sorted(mixed $value): mixed
|
||
{
|
||
if (! is_array($value)) {
|
||
return $value;
|
||
}
|
||
$value = array_map([self::class, 'sorted'], $value);
|
||
if (! array_is_list($value)) {
|
||
ksort($value);
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
private static function plain(mixed $value): mixed
|
||
{
|
||
if (is_object($value)) {
|
||
$value = get_object_vars($value);
|
||
}
|
||
|
||
return is_array($value) ? array_map([self::class, 'plain'], $value) : $value;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3b.4: GREEN-Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorBookTest`
|
||
Expected: `OK (16 tests, 61 assertions)`.
|
||
|
||
- [ ] **Step 3b.5: Lint**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldung (PSR-12 + WPCS, Zeilen ≤ 120 Zeichen).
|
||
|
||
- [ ] **Step 3b.6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/ColorBook.php tests/Unit/Integrations/Divi/Design/ColorBookTest.php
|
||
git commit -m "feat: ColorBook als reiner Farbbestand mit Auflösung nach ID/Alias/Label, Verweisen, Zyklusprüfung und Änderungsmenge" \
|
||
-m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 3c: `ColorDiff` – Änderungsliste und Zusammenfassung
|
||
|
||
- [ ] **Step 3c.1: Failing Test `ColorDiffTest` schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Design/ColorDiffTest.php`. Nutzt `ColorBookTest::stored()` aus 3b.
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\ColorBook;
|
||
use LIT\MCP\Integrations\Divi\Design\ColorDiff;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class ColorDiffTest extends TestCase
|
||
{
|
||
public function testNoChangeIsAnEmptyDiffEvenWhenOnlyDerivedFieldsDiffer(): void
|
||
{
|
||
$book = ColorBook::fromDivi(ColorBookTest::stored());
|
||
$touched = $book->with('gcid-q9swzy97df', [
|
||
'color' => '#5b3a29', 'label' => 'Holz dunkel', 'lastUpdated' => '2030-01-01T00:00:00.000Z',
|
||
'usedInPosts' => [7],
|
||
]);
|
||
|
||
self::assertSame([], ColorDiff::between($book, ColorBook::fromDivi(ColorBookTest::stored())));
|
||
self::assertSame([], ColorDiff::between($book, $touched));
|
||
}
|
||
|
||
public function testAddedChangedDeactivatedActivatedAndRemoved(): void
|
||
{
|
||
$before = ColorBook::fromDivi(ColorBookTest::stored());
|
||
$after = $before
|
||
->with('gcid-new0000001', ['color' => '#abc', 'label' => 'Neu'])
|
||
->with('gcid-q9swzy97df', ['color' => '#000000', 'label' => 'Holz dunkler'])
|
||
->with('gcid-aaaaaaaaaa', ['color' => '#111111', 'label' => 'Doppelt', 'status' => 'inactive'])
|
||
->with('gcid-bbbbbbbbbb', ['color' => '#222222', 'label' => 'doppelt', 'status' => 'active'])
|
||
->with('gcid-989stx15zc', ['color' => '#333333', 'label' => 'Holz hell', 'status' => 'temporary']);
|
||
|
||
self::assertSame([
|
||
['id' => 'gcid-989stx15zc', 'name' => 'Holz hell', 'action' => 'changed', 'fields' => ['color', 'status']],
|
||
['id' => 'gcid-aaaaaaaaaa', 'name' => 'Doppelt', 'action' => 'deactivated'],
|
||
['id' => 'gcid-bbbbbbbbbb', 'name' => 'doppelt', 'action' => 'activated'],
|
||
['id' => 'gcid-new0000001', 'name' => 'Neu', 'action' => 'added'],
|
||
['id' => 'gcid-q9swzy97df', 'name' => 'Holz dunkler', 'action' => 'changed',
|
||
'fields' => ['color', 'label']],
|
||
], ColorDiff::between($before, $after));
|
||
|
||
$restored = ColorBook::fromDivi(array_diff_key(ColorBookTest::stored(), ['gcid-aaaaaaaaaa' => 1]));
|
||
self::assertSame(
|
||
[['id' => 'gcid-aaaaaaaaaa', 'name' => 'Doppelt', 'action' => 'removed']],
|
||
ColorDiff::between($before, $restored)
|
||
);
|
||
}
|
||
|
||
public function testStatusSwitchWithFurtherChangesNamesTheOtherFields(): void
|
||
{
|
||
$before = ColorBook::fromDivi(ColorBookTest::stored());
|
||
$after = $before->with('gcid-aaaaaaaaaa', [
|
||
'color' => '#999999', 'label' => 'Doppelt', 'folder' => 'Marke', 'status' => 'inactive',
|
||
]);
|
||
|
||
self::assertSame(
|
||
[['id' => 'gcid-aaaaaaaaaa', 'name' => 'Doppelt', 'action' => 'deactivated',
|
||
'fields' => ['color', 'folder']]],
|
||
ColorDiff::between($before, $after)
|
||
);
|
||
}
|
||
|
||
public function testSummaryCountsTheActions(): void
|
||
{
|
||
self::assertSame(
|
||
'Farben: 1 angelegt, 2 geändert, 1 deaktiviert.',
|
||
ColorDiff::summary([
|
||
['action' => 'added'], ['action' => 'changed'], ['action' => 'changed'], ['action' => 'deactivated'],
|
||
])
|
||
);
|
||
self::assertSame(
|
||
'Farben: 1 geändert, 1 aktiviert, 1 gelöscht.',
|
||
ColorDiff::summary([['action' => 'removed'], ['action' => 'activated'], ['action' => 'changed']])
|
||
);
|
||
self::assertSame('Farben: keine Änderung.', ColorDiff::summary([]));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3c.2: RED-Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorDiffTest`
|
||
Expected: Abbruch mit `Error: Class "LIT\MCP\Integrations\Divi\Design\ColorDiff" not found`.
|
||
|
||
- [ ] **Step 3c.3: `ColorDiff` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Design/ColorDiff.php`. Reines PHP; sortiert nach ID.
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* What a color change does (spec §4 step 4, "diff"): per color added, changed (with the changed fields), deactivated
|
||
* or activated (status change; "fields" lists further changed fields), removed (only a restore can drop a color).
|
||
* lastUpdated and usedInPosts are no change of their own. Sorted by ID. Pure PHP.
|
||
*/
|
||
final class ColorDiff
|
||
{
|
||
private const COMPARED = ['color', 'folder', 'label', 'status'];
|
||
|
||
/**
|
||
* @return list<array{id: string, name: string, action: string, fields?: list<string>}>
|
||
* action: added|changed|deactivated|activated|removed
|
||
*/
|
||
public static function between(ColorBook $before, ColorBook $after): array
|
||
{
|
||
$ids = array_unique(array_merge(array_keys($before->data()), array_keys($after->data())));
|
||
sort($ids);
|
||
$rows = [];
|
||
foreach ($ids as $id) {
|
||
$id = (string) $id;
|
||
$was = $before->entry($id);
|
||
$is = $after->entry($id);
|
||
if ($was === null || $is === null) {
|
||
$book = $is === null ? $before : $after;
|
||
$rows[] = ['id' => $id, 'name' => $book->label($id), 'action' => $is === null ? 'removed' : 'added'];
|
||
continue;
|
||
}
|
||
$action = self::action($was['status'], $is['status']);
|
||
// A status switch is the action itself; "fields" then names only the other changes.
|
||
$skip = $action === 'changed' ? [] : ['status'];
|
||
$fields = array_values(array_filter(
|
||
self::COMPARED,
|
||
static fn (string $key): bool => $was[$key] !== $is[$key] && ! in_array($key, $skip, true)
|
||
));
|
||
$row = ['id' => $id, 'name' => $after->label($id), 'action' => $action];
|
||
if ($fields !== []) {
|
||
$row['fields'] = $fields;
|
||
}
|
||
if ($fields !== [] || $action !== 'changed') {
|
||
$rows[] = $row;
|
||
}
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* One German sentence for the dry run and the change log (spec §4 step 6).
|
||
*
|
||
* @param list<array<string, mixed>> $diff
|
||
*/
|
||
public static function summary(array $diff): string
|
||
{
|
||
$count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff));
|
||
$words = [
|
||
'added' => 'angelegt', 'changed' => 'geändert', 'deactivated' => 'deaktiviert',
|
||
'activated' => 'aktiviert', 'removed' => 'gelöscht',
|
||
];
|
||
$parts = [];
|
||
foreach ($words as $action => $word) {
|
||
if (isset($count[$action])) {
|
||
$parts[] = $count[$action] . ' ' . $word;
|
||
}
|
||
}
|
||
|
||
return 'Farben: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.';
|
||
}
|
||
|
||
private static function action(string $was, string $is): string
|
||
{
|
||
return match (true) {
|
||
$was === $is => 'changed',
|
||
$is === 'inactive' => 'deactivated',
|
||
$is === 'active' => 'activated',
|
||
default => 'changed',
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3c.4: GREEN-Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorDiffTest`
|
||
Expected: `OK (4 tests, 8 assertions)`.
|
||
|
||
- [ ] **Step 3c.5: Lint**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldung (PSR-12 + WPCS, Zeilen ≤ 120 Zeichen).
|
||
|
||
- [ ] **Step 3c.6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/ColorDiff.php tests/Unit/Integrations/Divi/Design/ColorDiffTest.php
|
||
git commit -m "feat: ColorDiff beschreibt Farbänderungen (angelegt, geändert, deaktiviert, aktiviert) samt Zusammenfassung" \
|
||
-m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 3 – Abschluss
|
||
|
||
- [ ] **Step 3.7: Gesamtlauf**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Unit-Tests grün (2877 + 53 neue = 2930), Lint ohne Meldung. Schlägt ein Architektur-Test fehl (`CoreIndependenceTest`, `DiviBoundaryTest`), steht in den neuen Klassen ein WordPress-/Divi-Aufruf, der dort nicht hingehört – entfernen, nicht den Test anpassen.
|
||
|
||
---
|
||
|
||
### Task 4: `VariableValue`, `VariableBook`, `VariableDiff` – reiner Variablenbestand (V1, V6, V7)
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/VariableValue.php`, `VariableBook.php`, `VariableDiff.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/VariableValueTest.php`, `VariableBookTest.php`, `VariableDiffTest.php` (alle neu, erben von `PHPUnit\Framework\TestCase`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LIT\MCP\Abilities\Rev::of()`, `ColorBook::now()` (aus Task 3, für gleiches Zeitstempelformat).
|
||
- Produces (Signaturen wie im Schnittstellenvertrag, keine Abweichung):
|
||
- `VariableValue::TYPES`, `VariableValue::check(string $type, string $value): ?string`.
|
||
- `VariableBook`: `fromDivi`, `data`, `rev`, `entry`, `typeOf`, `find`, `with`, `nextOrder`, `restoredFrom`, `onlyHere`, `newId`, `now`. Die Testdatei `VariableBookTest` liefert `VariableBookTest::stored()` (Speicherform: `numbers` ×3 darunter eine archivierte, `strings` ×2 mit einem Label, das auch in `numbers` vorkommt, `fonts`, `gradients`), `::fromGetter()` (Form von `get_global_variables()` mit `stdClass`-Gruppen, `allowedActions`, Customizer-Schriften und Gruppe `colors`) und `::variable(...)`; spätere Tests (`VariableAreaTest`, `VariableRestorePlannerTest`) dürfen sie benutzen.
|
||
- `VariableDiff::between(VariableBook, VariableBook)`, `VariableDiff::summary(array)`.
|
||
|
||
**Regeln dieses Tasks (alle durch Tests belegt):**
|
||
- `VariableValue::check()` setzt V6 um; `gradients` und alles außerhalb von `TYPES` liefert „Typ "…" ist nicht erlaubt; erlaubt: numbers, strings, images, links, fonts.“ (das Durchreichen von `gradients` ist Sache von `VariableBook`). Leerer Wert → „Wert fehlt (Typ "…").“; ungültiges UTF-8 wird abgelehnt. Links: `http(s)://…`, `/…` (auch `/kontakt/#formular`), `mailto:…`, `tel:…`, keine Leerzeichen, `<`, `>`, `"`. Bilder: nur Ziffern oder `http(s)://`-URL.
|
||
- `VariableBook` kennt die sieben Schlüssel `id, label, value, order, status, lastUpdated, variableType` (feste Reihenfolge, fremde Schlüssel wie `allowedActions` fallen weg); `id` und `variableType` stammen aus Schlüssel und Gruppe, nicht aus dem Eintrag; `order: null` (so archiviert der Builder) wird `''`. Typen in fester Reihenfolge `numbers, strings, images, links, fonts, gradients`; `colors`, unbekannte Typen, leere Gruppen und Einträge ohne `gvid-`-Präfix (Customizer-Schriften `--et_global_*_font`) entfallen. `gradients` bleibt unverändert erhalten. `with()` wirft `InvalidArgumentException` („Unbekannter Variablentyp "colors".“) für einen Typ, den Divi nicht speichert.
|
||
- `restoredFrom($snapshot)` (V1b): alle Einträge des Snapshots, dazu jeder Eintrag, den es nur im aktuellen Bestand gibt, mit `status: archived` und `order: ''` – jede aktuelle ID bleibt erhalten, wie `validate_global_variable_ids()` es verlangt. `onlyHere($snapshot)` nennt davon nur die bisher **aktiven** (für die Warnung im Probelauf).
|
||
- `VariableDiff`: Verglichen werden `label`, `value`, `order`, `status`. Statuswechsel nach `archived` = `archived`, von `archived` nach `active` = `activated`; dabei zählt `order` nicht (Archivieren leert sie) und `fields` nennt nur `label`/`value`. Ein Eintrag, der nachher fehlt, erzeugt keine Zeile (Variablen werden nie entfernt, V1).
|
||
|
||
#### Task 4a: `VariableValue` – Variablenwerte je Typ prüfen
|
||
|
||
- [ ] **Step 4a.1: Failing Test `VariableValueTest` schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Design/VariableValueTest.php`. Datenanbieter für gültige und ungültige Werte je Typ, Grenzen inklusive, `gradients` und `colors` als nicht anlegbare Typen.
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\VariableValue;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class VariableValueTest extends TestCase
|
||
{
|
||
/** @return array<string, array{string, string}> */
|
||
public static function validValues(): array
|
||
{
|
||
return [
|
||
'numbers px' => ['numbers', '8px'],
|
||
'numbers clamp' => ['numbers', 'clamp(1.5rem, 4vw, 2.5rem)'],
|
||
'numbers calc' => ['numbers', 'calc(100% - var(--gvid-f6yq6mayfy))'],
|
||
'strings' => ['strings', 'Handwerk aus Meisterhand – seit 1987'],
|
||
'strings with line feed' => ['strings', "Zeile 1\nZeile 2"],
|
||
'strings with quotes and markup chars' => ['strings', '"Hallo" & <willkommen>'],
|
||
'links https' => ['links', 'https://wordpress.ddev.site:8443/kontakt/'],
|
||
'links http' => ['links', 'http://example.com'],
|
||
'links relative' => ['links', '/kontakt/#formular'],
|
||
'links mailto' => ['links', 'mailto:info@example.com'],
|
||
'links tel' => ['links', 'tel:+49221123'],
|
||
'images id' => ['images', '1234'],
|
||
'images url' => ['images', 'https://example.com/a/600.jpg'],
|
||
'fonts' => ['fonts', 'Merriweather'],
|
||
'fonts with space and digits' => ['fonts', 'Source Sans 3'],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider validValues */
|
||
public function testValidValues(string $type, string $value): void
|
||
{
|
||
self::assertNull(VariableValue::check($type, $value));
|
||
}
|
||
|
||
/** @return array<string, array{string, string}> */
|
||
public static function invalidValues(): array
|
||
{
|
||
return [
|
||
'numbers empty' => ['numbers', ''],
|
||
'numbers semicolon' => ['numbers', '8px; color: red'],
|
||
'numbers braces' => ['numbers', '8px}'],
|
||
'numbers angle' => ['numbers', '<8px>'],
|
||
'numbers line feed' => ['numbers', "8px\n9px"],
|
||
'numbers too long' => ['numbers', str_repeat('1', 201)],
|
||
'strings empty' => ['strings', ''],
|
||
'strings control' => ['strings', "a\x07b"],
|
||
'strings tab' => ['strings', "a\tb"],
|
||
'strings carriage return' => ['strings', "a\r\nb"],
|
||
'strings too long' => ['strings', str_repeat('ä', 2001)],
|
||
'strings invalid utf8' => ['strings', "a\xC3\x28"],
|
||
'links empty' => ['links', ''],
|
||
'links javascript' => ['links', 'javascript:alert(1)'],
|
||
'links data' => ['links', 'data:text/html,x'],
|
||
'links anchor only' => ['links', '#kontakt'],
|
||
'links relative without slash' => ['links', 'kontakt/'],
|
||
'links with space' => ['links', 'https://example.com/a b'],
|
||
'links mailto empty' => ['links', 'mailto:'],
|
||
'links too long' => ['links', 'https://example.com/' . str_repeat('a', 2000)],
|
||
'images negative' => ['images', '-5'],
|
||
'images relative path' => ['images', '/wp-content/a.jpg'],
|
||
'images text' => ['images', 'Werkstattbild'],
|
||
'images javascript' => ['images', 'javascript:alert(1)'],
|
||
'fonts empty' => ['fonts', ''],
|
||
'fonts semicolon' => ['fonts', 'Lato; color: red'],
|
||
'fonts quote' => ['fonts', "Lato'"],
|
||
'fonts backslash' => ['fonts', 'Lato\\'],
|
||
'fonts too long' => ['fonts', str_repeat('a', 101)],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider invalidValues */
|
||
public function testInvalidValuesGetAGermanMessage(string $type, string $value): void
|
||
{
|
||
$message = VariableValue::check($type, $value);
|
||
|
||
self::assertIsString($message);
|
||
self::assertNotSame('', $message);
|
||
}
|
||
|
||
public function testBoundsAreInclusive(): void
|
||
{
|
||
self::assertNull(VariableValue::check('numbers', str_repeat('1', 200)));
|
||
self::assertNull(VariableValue::check('strings', str_repeat('ä', 2000)));
|
||
self::assertNull(VariableValue::check('fonts', str_repeat('a', 100)));
|
||
self::assertNull(VariableValue::check('links', 'https://example.com/' . str_repeat('a', 1980)));
|
||
}
|
||
|
||
public function testGradientsAndUnknownTypesAreNotCreatable(): void
|
||
{
|
||
self::assertSame(
|
||
'Typ "gradients" ist nicht erlaubt; erlaubt: numbers, strings, images, links, fonts.',
|
||
VariableValue::check('gradients', 'linear-gradient(red, blue)')
|
||
);
|
||
self::assertSame(
|
||
'Typ "colors" ist nicht erlaubt; erlaubt: numbers, strings, images, links, fonts.',
|
||
VariableValue::check('colors', '#fff')
|
||
);
|
||
self::assertSame(['numbers', 'strings', 'images', 'links', 'fonts'], VariableValue::TYPES);
|
||
}
|
||
|
||
public function testMessagesNameTheTypeOrTheRule(): void
|
||
{
|
||
self::assertSame('Wert fehlt (Typ "fonts").', VariableValue::check('fonts', ''));
|
||
self::assertStringStartsWith('Zahlenwert:', (string) VariableValue::check('numbers', '1;'));
|
||
self::assertStringStartsWith('Link:', (string) VariableValue::check('links', 'x'));
|
||
self::assertStringStartsWith('Bild:', (string) VariableValue::check('images', 'x'));
|
||
self::assertStringStartsWith('Schrift:', (string) VariableValue::check('fonts', 'a{'));
|
||
self::assertStringStartsWith('Textwert:', (string) VariableValue::check('strings', "a\x00"));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4a.2: RED-Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableValueTest`
|
||
Expected: Abbruch mit `Error: Class "LIT\MCP\Integrations\Divi\Design\VariableValue" not found`.
|
||
|
||
- [ ] **Step 4a.3: `VariableValue` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Design/VariableValue.php`. Reines PHP; Regexe und Längen.
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Checks the value of a global variable before it goes to Divi (ruling V6, D7 principle). The type "gradients"
|
||
* (new in Divi 5.15) is passed through by VariableBook, but the tools never create or change it. The check of fonts
|
||
* against Divi's font list follows with 4d. Pure PHP.
|
||
*/
|
||
final class VariableValue
|
||
{
|
||
/** Types the tools may create (V6). */
|
||
public const TYPES = ['numbers', 'strings', 'images', 'links', 'fonts'];
|
||
|
||
private const NUMBERS_MAX = 200;
|
||
|
||
private const STRINGS_MAX = 2000;
|
||
|
||
private const URL_MAX = 2000;
|
||
|
||
private const FONT_MAX = 100;
|
||
|
||
/** Control characters except the line feed. */
|
||
private const CONTROL = '/[\x00-\x09\x0B-\x1F\x7F]/';
|
||
|
||
private const URL = '/^(?:https?:\/\/[^\s<>"]+|\/[^\s<>"]*|mailto:[^\s<>"]+|tel:[^\s<>"]+)$/iD';
|
||
|
||
private const WEB_URL = '/^https?:\/\/[^\s<>"]+$/iD';
|
||
|
||
/** @return string|null Null when $value is valid for $type, else a German message. */
|
||
public static function check(string $type, string $value): ?string
|
||
{
|
||
if (! in_array($type, self::TYPES, true)) {
|
||
return sprintf('Typ "%s" ist nicht erlaubt; erlaubt: %s.', $type, implode(', ', self::TYPES));
|
||
}
|
||
if ($value === '') {
|
||
return sprintf('Wert fehlt (Typ "%s").', $type);
|
||
}
|
||
if (! mb_check_encoding($value, 'UTF-8')) {
|
||
return 'Wert ist kein gültiges UTF-8.';
|
||
}
|
||
|
||
return match ($type) {
|
||
'numbers' => self::numbers($value),
|
||
'strings' => self::strings($value),
|
||
'links' => self::links($value),
|
||
'images' => self::images($value),
|
||
default => self::font($value),
|
||
};
|
||
}
|
||
|
||
private static function numbers(string $value): ?string
|
||
{
|
||
if (mb_strlen($value) > self::NUMBERS_MAX || preg_match('/[;{}<>\x00-\x1F\x7F]/', $value) === 1) {
|
||
return 'Zahlenwert: ein CSS-Ausdruck mit höchstens 200 Zeichen ohne ; { } < > und Zeilenumbruch '
|
||
. '(z. B. 8px oder clamp(1.5rem, 4vw, 2.5rem)).';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static function strings(string $value): ?string
|
||
{
|
||
if (mb_strlen($value) > self::STRINGS_MAX || preg_match(self::CONTROL, $value) === 1) {
|
||
return 'Textwert: höchstens 2000 Zeichen, keine Steuerzeichen außer dem Zeilenumbruch.';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static function links(string $value): ?string
|
||
{
|
||
if (mb_strlen($value) > self::URL_MAX || preg_match(self::URL, $value) !== 1) {
|
||
return 'Link: eine http(s)://- oder /-URL, mailto: oder tel: mit höchstens 2000 Zeichen.';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static function images(string $value): ?string
|
||
{
|
||
$valid = preg_match('/^\d+$/D', $value) === 1
|
||
|| (mb_strlen($value) <= self::URL_MAX && preg_match(self::WEB_URL, $value) === 1);
|
||
|
||
return $valid ? null : 'Bild: eine Anhang-ID (Ziffern) oder eine http(s)://-URL mit höchstens 2000 Zeichen.';
|
||
}
|
||
|
||
private static function font(string $value): ?string
|
||
{
|
||
if (mb_strlen($value) > self::FONT_MAX || preg_match('/[;{}<>"\'\\\\\x00-\x1F\x7F]/', $value) === 1) {
|
||
return 'Schrift: ein Schriftname mit höchstens 100 Zeichen ohne ; { } < > " \' \\.';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4a.4: GREEN-Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableValueTest`
|
||
Expected: `OK (47 tests, 86 assertions)`.
|
||
|
||
- [ ] **Step 4a.5: Lint**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldung (PSR-12 + WPCS, Zeilen ≤ 120 Zeichen).
|
||
|
||
- [ ] **Step 4a.6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/VariableValue.php tests/Unit/Integrations/Divi/Design/VariableValueTest.php
|
||
git commit -m "feat: VariableValue prüft Variablenwerte je Typ (numbers, strings, images, links, fonts)" \
|
||
-m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 4b: `VariableBook` – unveränderlicher Variablenbestand
|
||
|
||
- [ ] **Step 4b.1: Failing Test `VariableBookTest` schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Design/VariableBookTest.php`. Deckt ab: Speicherform und Lese-Form von `get_global_variables()` ergeben denselben Bestand und dieselbe `rev()`, die echte Divi-Fixture `tests/Fixtures/divi/stage4/global-variables.json` (`stored` und `get_global_variables`), Durchreichen von `gradients`, Auffüllen und Normalisieren, `find` nach ID/Label mit und ohne Typ inklusive Mehrdeutigkeit, `with`, `nextOrder`, `restoredFrom`/`onlyHere` (auch archivierte Einträge mit `order`), `newId`, `now`.
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableBook;
|
||
use PHPUnit\Framework\TestCase;
|
||
use stdClass;
|
||
|
||
final class VariableBookTest extends TestCase
|
||
{
|
||
/** @return array<string, mixed> One stored variable (formats §2). */
|
||
public static function variable(
|
||
string $type,
|
||
string $id,
|
||
string $label,
|
||
string $value,
|
||
string $order = '1',
|
||
string $status = 'active'
|
||
): array {
|
||
return [
|
||
'id' => $id, 'label' => $label, 'value' => $value, 'order' => $order, 'status' => $status,
|
||
'lastUpdated' => '2026-09-27T22:21:39.000Z', 'variableType' => $type,
|
||
];
|
||
}
|
||
|
||
/** @return array<string, array<string, array<string, mixed>>> The stored shape (et_divi_global_variables). */
|
||
public static function stored(): array
|
||
{
|
||
return [
|
||
'numbers' => [
|
||
'gvid-f6yq6mayfy' => self::variable('numbers', 'gvid-f6yq6mayfy', 'Eckenradius', '8px', '1'),
|
||
'gvid-5dec72fwsc' => self::variable('numbers', 'gvid-5dec72fwsc', 'Abstand Karte', '2rem', '2'),
|
||
'gvid-old0000001' => self::variable('numbers', 'gvid-old0000001', 'Alt', '1px', '', 'archived'),
|
||
],
|
||
'strings' => [
|
||
'gvid-7uckqqhmrh' => self::variable('strings', 'gvid-7uckqqhmrh', 'Slogan', 'Handwerk', '1'),
|
||
'gvid-dup0000001' => self::variable('strings', 'gvid-dup0000001', 'Eckenradius', 'Text', '2'),
|
||
],
|
||
'fonts' => [
|
||
'gvid-d4a5bsz2gt' => self::variable('fonts', 'gvid-d4a5bsz2gt', 'Akzentschrift', 'Merriweather'),
|
||
],
|
||
'gradients' => [
|
||
'gvid-grad000001' => self::variable(
|
||
'gradients',
|
||
'gvid-grad000001',
|
||
'Verlauf',
|
||
'linear-gradient(red, blue)'
|
||
),
|
||
],
|
||
];
|
||
}
|
||
|
||
/** @return array<string, mixed> The shape of GlobalData::get_global_variables() (formats §2, verified in DDEV). */
|
||
public static function fromGetter(): array
|
||
{
|
||
$actions = ['editLabel' => true, 'editValue' => true, 'reorder' => true, 'remove' => true];
|
||
$stored = self::stored();
|
||
$withActions = static fn (array $group): array => array_map(
|
||
static fn (array $entry): array => $entry + ['allowedActions' => $actions],
|
||
$group
|
||
);
|
||
|
||
return [
|
||
'numbers' => (object) $withActions($stored['numbers']),
|
||
'strings' => (object) $withActions($stored['strings']),
|
||
'images' => new stdClass(),
|
||
'links' => new stdClass(),
|
||
'colors' => (object) ['gcid-x1' => ['id' => 'gcid-x1', 'label' => 'Farbe', 'value' => '#fff']],
|
||
'fonts' => (object) ([
|
||
'--et_global_heading_font' => ['id' => '--et_global_heading_font', 'label' => 'Überschrift',
|
||
'value' => 'Open Sans', 'status' => 'active', 'allowedActions' => $actions],
|
||
'--et_global_body_font' => ['id' => '--et_global_body_font', 'label' => 'Fließtext',
|
||
'value' => 'Open Sans', 'status' => 'active', 'allowedActions' => $actions],
|
||
] + $withActions($stored['fonts'])),
|
||
'gradients' => (object) $withActions($stored['gradients']),
|
||
];
|
||
}
|
||
|
||
public function testStoredShapeIsKeptAsItIs(): void
|
||
{
|
||
self::assertSame(self::stored(), VariableBook::fromDivi(self::stored())->data());
|
||
}
|
||
|
||
public function testGetterShapeEqualsTheStoredShape(): void
|
||
{
|
||
$fromGetter = VariableBook::fromDivi(self::fromGetter());
|
||
$fromStored = VariableBook::fromDivi(self::stored());
|
||
|
||
self::assertSame($fromStored->data(), $fromGetter->data());
|
||
self::assertSame($fromStored->rev(), $fromGetter->rev());
|
||
self::assertNull($fromGetter->typeOf('--et_global_body_font'));
|
||
self::assertNull($fromGetter->typeOf('gcid-x1'));
|
||
self::assertArrayNotHasKey('colors', $fromGetter->data());
|
||
self::assertArrayNotHasKey('images', $fromGetter->data(), 'empty groups are dropped');
|
||
self::assertArrayNotHasKey('allowedActions', $fromGetter->entry('numbers', 'gvid-f6yq6mayfy') ?? []);
|
||
}
|
||
|
||
public function testGradientsPassThroughUnchanged(): void
|
||
{
|
||
$book = VariableBook::fromDivi(self::stored());
|
||
|
||
self::assertSame('linear-gradient(red, blue)', $book->entry('gradients', 'gvid-grad000001')['value']);
|
||
self::assertSame('gradients', $book->typeOf('gvid-grad000001'));
|
||
self::assertSame(
|
||
'gradients',
|
||
$book->with('gradients', 'gvid-grad000001', ['label' => 'Verlauf 2', 'value' => 'x'])
|
||
->entry('gradients', 'gvid-grad000001')['variableType']
|
||
);
|
||
}
|
||
|
||
public function testMissingKeysAreFilledAndIdAndTypeComeFromTheSlot(): void
|
||
{
|
||
$book = VariableBook::fromDivi([
|
||
'numbers' => ['gvid-x1' => ['id' => 'other', 'label' => 'A', 'value' => 8, 'order' => null,
|
||
'variableType' => 'strings', 'extra' => 'dropped']],
|
||
'strings' => ['kein-gvid' => ['label' => 'B', 'value' => 'x']],
|
||
]);
|
||
|
||
self::assertSame([
|
||
'id' => 'gvid-x1', 'label' => 'A', 'value' => '8', 'order' => '', 'status' => 'active',
|
||
'lastUpdated' => '', 'variableType' => 'numbers',
|
||
], $book->entry('numbers', 'gvid-x1'));
|
||
self::assertArrayNotHasKey('strings', $book->data());
|
||
}
|
||
|
||
public function testRevIsStableAndChangesWithTheContent(): void
|
||
{
|
||
$book = VariableBook::fromDivi(self::stored());
|
||
$reversed = VariableBook::fromDivi(array_map(
|
||
static fn (array $group): array => array_reverse($group, true),
|
||
array_reverse(self::stored(), true)
|
||
));
|
||
|
||
self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', $book->rev());
|
||
self::assertSame($book->rev(), $reversed->rev());
|
||
self::assertNotSame(
|
||
$book->rev(),
|
||
$book->with('numbers', 'gvid-f6yq6mayfy', ['label' => 'X', 'value' => '9px'])->rev()
|
||
);
|
||
}
|
||
|
||
public function testRealDiviFixtureAndItsGetterShapeAreTheSame(): void
|
||
{
|
||
$json = (string) file_get_contents(dirname(__DIR__, 4) . '/Fixtures/divi/stage4/global-variables.json');
|
||
$fixture = json_decode($json, true);
|
||
$stored = VariableBook::fromDivi($fixture['stored']);
|
||
$read = VariableBook::fromDivi($fixture['get_global_variables']);
|
||
|
||
self::assertSame($stored->rev(), $read->rev());
|
||
self::assertSame(['numbers', 'strings', 'images', 'links', 'fonts'], array_keys($read->data()));
|
||
self::assertSame('8px', $read->entry('numbers', 'gvid-f6yq6mayfy')['value']);
|
||
self::assertNull($read->typeOf('--et_global_heading_font'));
|
||
}
|
||
|
||
public function testFindByIdAndLabel(): void
|
||
{
|
||
$book = VariableBook::fromDivi(self::stored());
|
||
|
||
self::assertSame(['type' => 'numbers', 'id' => 'gvid-f6yq6mayfy'], $book->find('gvid-f6yq6mayfy'));
|
||
self::assertSame(['type' => 'strings', 'id' => 'gvid-7uckqqhmrh'], $book->find(' SLOGAN '));
|
||
self::assertSame(['type' => 'fonts', 'id' => 'gvid-d4a5bsz2gt'], $book->find('Akzentschrift', 'fonts'));
|
||
self::assertNull($book->find('Akzentschrift', 'numbers'));
|
||
self::assertNull($book->find('gvid-d4a5bsz2gt', 'numbers'));
|
||
self::assertNull($book->find('Fehlt'));
|
||
self::assertNull($book->find(''));
|
||
}
|
||
|
||
public function testAmbiguousLabelListsTheIdsAndAnArgumentTypeResolvesIt(): void
|
||
{
|
||
$book = VariableBook::fromDivi(self::stored());
|
||
|
||
self::assertSame(['type' => 'numbers', 'id' => 'gvid-f6yq6mayfy'], $book->find('Eckenradius', 'numbers'));
|
||
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage(
|
||
'Name der globalen Variable "Eckenradius" ist mehrdeutig; bitte die ID angeben: '
|
||
. 'gvid-f6yq6mayfy, gvid-dup0000001.'
|
||
);
|
||
$book->find('Eckenradius');
|
||
}
|
||
|
||
public function testWithIsImmutableAndKeepsTheTypeOrder(): void
|
||
{
|
||
$book = VariableBook::fromDivi(self::stored());
|
||
$added = $book->with('images', 'gvid-img0000001', ['label' => 'Bild', 'value' => '12', 'order' => '1']);
|
||
|
||
self::assertCount(3, $book->data()['numbers']);
|
||
self::assertSame(['numbers', 'strings', 'images', 'fonts', 'gradients'], array_keys($added->data()));
|
||
self::assertSame('images', $added->entry('images', 'gvid-img0000001')['variableType']);
|
||
self::assertSame('active', $added->entry('images', 'gvid-img0000001')['status']);
|
||
self::assertNull($book->entry('images', 'gvid-img0000001'));
|
||
}
|
||
|
||
public function testWithRejectsTypesDiviDoesNotStore(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage('Unbekannter Variablentyp "colors".');
|
||
|
||
VariableBook::fromDivi([])->with('colors', 'gvid-x1', ['label' => 'A', 'value' => '#fff']);
|
||
}
|
||
|
||
public function testNextOrderIsTheHighestOrderPlusOne(): void
|
||
{
|
||
$book = VariableBook::fromDivi(self::stored());
|
||
|
||
self::assertSame('3', $book->nextOrder('numbers'), 'the archived entry with order "" does not count');
|
||
self::assertSame('2', $book->nextOrder('fonts'));
|
||
self::assertSame('1', $book->nextOrder('images'));
|
||
self::assertSame('1', $book->nextOrder('colors'));
|
||
self::assertSame('11', $book->with('links', 'gvid-l1', ['label' => 'L', 'value' => '/', 'order' => '10'])
|
||
->nextOrder('links'));
|
||
}
|
||
|
||
public function testRestoredFromKeepsCurrentOnlyVariablesArchived(): void
|
||
{
|
||
$current = VariableBook::fromDivi(self::stored())
|
||
->with('numbers', 'gvid-new0000001', ['label' => 'Neu', 'value' => '4px', 'order' => '3'])
|
||
->with('images', 'gvid-img0000001', ['label' => 'Bild', 'value' => '12', 'order' => '1'])
|
||
->with('strings', 'gvid-7uckqqhmrh', ['label' => 'Slogan neu', 'value' => 'Geändert', 'order' => '1']);
|
||
$snapshot = VariableBook::fromDivi(self::stored());
|
||
|
||
$restored = $current->restoredFrom($snapshot);
|
||
|
||
self::assertSame('Slogan', $restored->entry('strings', 'gvid-7uckqqhmrh')['label'], 'snapshot wins');
|
||
self::assertSame(
|
||
['label' => 'Neu', 'value' => '4px', 'order' => '', 'status' => 'archived'],
|
||
array_intersect_key(
|
||
$restored->entry('numbers', 'gvid-new0000001') ?? [],
|
||
array_flip(['status', 'order', 'label', 'value'])
|
||
)
|
||
);
|
||
self::assertSame('archived', $restored->entry('images', 'gvid-img0000001')['status']);
|
||
self::assertSame('', $restored->entry('images', 'gvid-img0000001')['order']);
|
||
self::assertSame('archived', $restored->entry('numbers', 'gvid-old0000001')['status']);
|
||
// Every ID of the current inventory survives, as Divi demands.
|
||
foreach ($current->data() as $type => $entries) {
|
||
foreach (array_keys($entries) as $id) {
|
||
self::assertNotNull($restored->entry($type, (string) $id), $id);
|
||
}
|
||
}
|
||
self::assertSame($snapshot->rev(), $snapshot->restoredFrom($snapshot)->rev());
|
||
}
|
||
|
||
public function testRestoredArchivedEntryLosesItsOrder(): void
|
||
{
|
||
$current = VariableBook::fromDivi(self::stored())
|
||
->with('fonts', 'gvid-gone000001', [
|
||
'label' => 'Weg', 'value' => 'Lato', 'order' => '7', 'status' => 'archived',
|
||
]);
|
||
|
||
$restored = $current->restoredFrom(VariableBook::fromDivi(self::stored()));
|
||
|
||
self::assertSame(['archived', ''], [
|
||
$restored->entry('fonts', 'gvid-gone000001')['status'],
|
||
$restored->entry('fonts', 'gvid-gone000001')['order'],
|
||
]);
|
||
}
|
||
|
||
public function testOnlyHereListsTheActiveVariablesARestoreWouldArchive(): void
|
||
{
|
||
$current = VariableBook::fromDivi(self::stored())
|
||
->with('numbers', 'gvid-new0000001', ['label' => 'Neu', 'value' => '4px'])
|
||
->with('fonts', 'gvid-gone000001', ['label' => 'Weg', 'value' => 'Lato', 'status' => 'archived']);
|
||
|
||
self::assertSame(
|
||
[['type' => 'numbers', 'id' => 'gvid-new0000001', 'label' => 'Neu']],
|
||
$current->onlyHere(VariableBook::fromDivi(self::stored()))
|
||
);
|
||
self::assertSame([], $current->onlyHere($current));
|
||
}
|
||
|
||
public function testNewIdHasTheBuilderFormat(): void
|
||
{
|
||
self::assertMatchesRegularExpression('/^gvid-[0-9a-z]{10}$/', VariableBook::newId());
|
||
self::assertNotSame(VariableBook::newId(), VariableBook::newId());
|
||
self::assertSame(
|
||
'gvid-zzzzzzzzzz',
|
||
VariableBook::newId(static fn (int $length): string => str_repeat('z', $length))
|
||
);
|
||
self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', VariableBook::now());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4b.2: RED-Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableBookTest`
|
||
Expected: Abbruch mit `Error: Class "LIT\MCP\Integrations\Divi\Design\VariableBook" not found`.
|
||
|
||
- [ ] **Step 4b.3: `VariableBook` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Design/VariableBook.php`. Reines PHP. `rev()` wie bei `ColorBook` (Präfix `variables`). `now()` ruft `ColorBook::now()` auf (ein Zeitstempelformat für beide Bereiche).
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use Closure;
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Abilities\Rev;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The global variables in Divi's stored format (formats §2): type => id => {id, label, value, order, status,
|
||
* lastUpdated, variableType}. Takes the stored shape and the shape of GlobalData::get_global_variables() alike:
|
||
* groups and entries may be objects, "allowedActions", the customizer fonts (--et_global_*_font), the group "colors"
|
||
* and empty groups are dropped. The type "gradients" (Divi 5.15) is kept unchanged (ruling V6). Variables are never
|
||
* removed, only archived (ruling V1): restoredFrom() keeps every current variable. Immutable. Pure PHP.
|
||
*/
|
||
final class VariableBook
|
||
{
|
||
/** Types in the order of Divi's stored data; VariableValue::TYPES plus the pass-through "gradients". */
|
||
private const TYPES = ['numbers', 'strings', 'images', 'links', 'fonts', 'gradients'];
|
||
|
||
private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||
|
||
/** @param array<string, array<string, array<string, mixed>>> $data */
|
||
private function __construct(private array $data)
|
||
{
|
||
}
|
||
|
||
/** @param array<array-key, mixed>|object $raw Stored data or get_global_variables(). */
|
||
public static function fromDivi(array|object $raw): self
|
||
{
|
||
$raw = self::plain($raw);
|
||
$data = [];
|
||
foreach (self::TYPES as $type) {
|
||
foreach (is_array($raw[$type] ?? null) ? $raw[$type] : [] as $id => $entry) {
|
||
// Customizer fonts have IDs like "--et_global_body_font"; Divi stores only "gvid-" variables.
|
||
if (is_array($entry) && str_starts_with((string) $id, 'gvid-')) {
|
||
$data[$type][(string) $id] = self::normalize($type, (string) $id, $entry);
|
||
}
|
||
}
|
||
}
|
||
|
||
return new self($data);
|
||
}
|
||
|
||
/** @return array<string, array<string, array<string, mixed>>> */
|
||
public function data(): array
|
||
{
|
||
return $this->data;
|
||
}
|
||
|
||
public function rev(): string
|
||
{
|
||
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
|
||
$json = (string) json_encode(self::sorted($this->data), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
|
||
return Rev::of('variables' . "\0" . $json);
|
||
}
|
||
|
||
/** @return array<string, mixed>|null */
|
||
public function entry(string $type, string $id): ?array
|
||
{
|
||
return $this->data[$type][$id] ?? null;
|
||
}
|
||
|
||
public function typeOf(string $id): ?string
|
||
{
|
||
foreach ($this->data as $type => $entries) {
|
||
if (isset($entries[$id])) {
|
||
return $type;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The variable $needle names: an ID, else the one variable of that label (case-insensitive, trimmed) –
|
||
* optionally only within $type.
|
||
*
|
||
* @return array{type: string, id: string}|null
|
||
* @throws InvalidArgumentException When several variables share the label (ruling S13a).
|
||
*/
|
||
public function find(string $needle, ?string $type = null): ?array
|
||
{
|
||
$key = mb_strtolower(trim($needle));
|
||
$hits = [];
|
||
foreach ($this->data as $kind => $entries) {
|
||
if ($type !== null && $kind !== $type) {
|
||
continue;
|
||
}
|
||
if (isset($entries[$needle])) {
|
||
return ['type' => $kind, 'id' => $needle];
|
||
}
|
||
foreach ($entries as $id => $entry) {
|
||
if ($key !== '' && mb_strtolower(trim((string) $entry['label'])) === $key) {
|
||
$hits[] = ['type' => $kind, 'id' => (string) $id];
|
||
}
|
||
}
|
||
}
|
||
if (count($hits) > 1) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
'Name der globalen Variable "%s" ist mehrdeutig; bitte die ID angeben: %s.',
|
||
trim($needle),
|
||
implode(', ', array_column($hits, 'id'))
|
||
));
|
||
}
|
||
|
||
return $hits[0] ?? null;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $entry Added or replaced; missing keys are filled, id and variableType are set.
|
||
* @throws InvalidArgumentException For a type Divi does not store.
|
||
*/
|
||
public function with(string $type, string $id, array $entry): self
|
||
{
|
||
if (! in_array($type, self::TYPES, true)) {
|
||
throw new InvalidArgumentException(sprintf('Unbekannter Variablentyp "%s".', $type));
|
||
}
|
||
$data = $this->data;
|
||
$data[$type][$id] = self::normalize($type, $id, $entry);
|
||
|
||
return new self(self::inTypeOrder($data));
|
||
}
|
||
|
||
/** The order for a variable appended to $type: the highest numeric order there plus one, as a string. */
|
||
public function nextOrder(string $type): string
|
||
{
|
||
$highest = 0;
|
||
foreach ($this->data[$type] ?? [] as $entry) {
|
||
if (is_numeric($entry['order'])) {
|
||
$highest = max($highest, (int) $entry['order']);
|
||
}
|
||
}
|
||
|
||
return (string) ($highest + 1);
|
||
}
|
||
|
||
/**
|
||
* The inventory a restore writes (ruling V1b): every variable of $snapshot, and every variable that exists only
|
||
* here stays – archived, with order "" (Divi refuses a save that drops a stored ID).
|
||
*/
|
||
public function restoredFrom(self $snapshot): self
|
||
{
|
||
$data = $snapshot->data;
|
||
foreach ($this->data as $type => $entries) {
|
||
foreach ($entries as $id => $entry) {
|
||
if (! isset($snapshot->data[$type][$id])) {
|
||
$data[$type][$id] = array_merge($entry, ['status' => 'archived', 'order' => '']);
|
||
}
|
||
}
|
||
}
|
||
|
||
return new self(self::inTypeOrder($data));
|
||
}
|
||
|
||
/**
|
||
* The variables restoredFrom() would archive: active ones that are not in $snapshot (already archived ones stay
|
||
* archived and need no warning).
|
||
*
|
||
* @return list<array{type: string, id: string, label: string}>
|
||
*/
|
||
public function onlyHere(self $snapshot): array
|
||
{
|
||
$rows = [];
|
||
foreach ($this->data as $type => $entries) {
|
||
foreach ($entries as $id => $entry) {
|
||
if (! isset($snapshot->data[$type][$id]) && $entry['status'] !== 'archived') {
|
||
$rows[] = ['type' => $type, 'id' => (string) $id, 'label' => (string) $entry['label']];
|
||
}
|
||
}
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
/** A new variable ID like the Visual Builder's: "gvid-" and 10 characters of [0-9a-z] (formats §2). */
|
||
public static function newId(?Closure $random = null): string
|
||
{
|
||
$random ??= static function (int $length): string {
|
||
$id = '';
|
||
for ($i = 0; $i < $length; ++$i) {
|
||
$id .= self::ALPHABET[random_int(0, 35)];
|
||
}
|
||
|
||
return $id;
|
||
};
|
||
|
||
return 'gvid-' . $random(10);
|
||
}
|
||
|
||
/** lastUpdated of a variable written now (same format as a color's). */
|
||
public static function now(): string
|
||
{
|
||
return ColorBook::now();
|
||
}
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $entry
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function normalize(string $type, string $id, array $entry): array
|
||
{
|
||
$text = static fn (string $key, string $default): string => is_scalar($entry[$key] ?? null)
|
||
? (string) $entry[$key]
|
||
: $default;
|
||
|
||
return [
|
||
'id' => $id,
|
||
'label' => $text('label', ''),
|
||
'value' => $text('value', ''),
|
||
'order' => $text('order', ''),
|
||
'status' => $text('status', 'active'),
|
||
'lastUpdated' => $text('lastUpdated', ''),
|
||
'variableType' => $type,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, array<string, array<string, mixed>>> $data
|
||
* @return array<string, array<string, array<string, mixed>>>
|
||
*/
|
||
private static function inTypeOrder(array $data): array
|
||
{
|
||
$ordered = [];
|
||
foreach (self::TYPES as $type) {
|
||
if (isset($data[$type])) {
|
||
$ordered[$type] = $data[$type];
|
||
}
|
||
}
|
||
|
||
return $ordered;
|
||
}
|
||
|
||
private static function sorted(mixed $value): mixed
|
||
{
|
||
if (! is_array($value)) {
|
||
return $value;
|
||
}
|
||
$value = array_map([self::class, 'sorted'], $value);
|
||
if (! array_is_list($value)) {
|
||
ksort($value);
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
private static function plain(mixed $value): mixed
|
||
{
|
||
if (is_object($value)) {
|
||
$value = get_object_vars($value);
|
||
}
|
||
|
||
return is_array($value) ? array_map([self::class, 'plain'], $value) : $value;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4b.4: GREEN-Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableBookTest`
|
||
Expected: `OK (15 tests, 64 assertions)`.
|
||
|
||
- [ ] **Step 4b.5: Lint**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldung (PSR-12 + WPCS, Zeilen ≤ 120 Zeichen).
|
||
|
||
- [ ] **Step 4b.6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/VariableBook.php tests/Unit/Integrations/Divi/Design/VariableBookTest.php
|
||
git commit -m "feat: VariableBook als reiner Variablenbestand mit Lese-/Speicherform, Archivierung statt Löschen und Restore-Vereinigung" \
|
||
-m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 4c: `VariableDiff` – Änderungsliste und Zusammenfassung
|
||
|
||
- [ ] **Step 4c.1: Failing Test `VariableDiffTest` schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Design/VariableDiffTest.php`. Nutzt `VariableBookTest::stored()` aus 4b.
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\VariableBook;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableDiff;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class VariableDiffTest extends TestCase
|
||
{
|
||
public function testNoChangeIsAnEmptyDiffEvenWhenOnlyLastUpdatedDiffers(): void
|
||
{
|
||
$book = VariableBook::fromDivi(VariableBookTest::stored());
|
||
$touched = $book->with('numbers', 'gvid-f6yq6mayfy', [
|
||
'label' => 'Eckenradius', 'value' => '8px', 'order' => '1', 'lastUpdated' => '2030-01-01T00:00:00.000Z',
|
||
]);
|
||
|
||
self::assertSame([], VariableDiff::between($book, VariableBook::fromDivi(VariableBookTest::stored())));
|
||
self::assertSame([], VariableDiff::between($book, $touched));
|
||
}
|
||
|
||
public function testAddedChangedArchivedAndActivated(): void
|
||
{
|
||
$before = VariableBook::fromDivi(VariableBookTest::stored());
|
||
$after = $before
|
||
->with('fonts', 'gvid-new0000001', ['label' => 'Neu', 'value' => 'Lato', 'order' => '2'])
|
||
->with('numbers', 'gvid-f6yq6mayfy', ['label' => 'Radius', 'value' => '12px', 'order' => '1'])
|
||
->with('numbers', 'gvid-5dec72fwsc', ['label' => 'Abstand Karte', 'value' => '2rem', 'order' => '2',
|
||
'status' => 'archived'])
|
||
->with('numbers', 'gvid-old0000001', ['label' => 'Alt', 'value' => '1px', 'order' => '3']);
|
||
|
||
self::assertSame([
|
||
['type' => 'fonts', 'id' => 'gvid-new0000001', 'name' => 'Neu', 'action' => 'added'],
|
||
['type' => 'numbers', 'id' => 'gvid-5dec72fwsc', 'name' => 'Abstand Karte', 'action' => 'archived'],
|
||
['type' => 'numbers', 'id' => 'gvid-f6yq6mayfy', 'name' => 'Radius', 'action' => 'changed',
|
||
'fields' => ['label', 'value']],
|
||
['type' => 'numbers', 'id' => 'gvid-old0000001', 'name' => 'Alt', 'action' => 'activated'],
|
||
], VariableDiff::between($before, $after));
|
||
}
|
||
|
||
public function testArchivingClearsTheOrderWithoutAFieldEntryButLabelAndValueChangesShow(): void
|
||
{
|
||
$before = VariableBook::fromDivi(VariableBookTest::stored());
|
||
$archived = $before->with('numbers', 'gvid-5dec72fwsc', [
|
||
'label' => 'Abstand Karte', 'value' => '2rem', 'order' => '', 'status' => 'archived',
|
||
]);
|
||
$archivedAndRenamed = $before->with('numbers', 'gvid-5dec72fwsc', [
|
||
'label' => 'Abstand', 'value' => '2rem', 'order' => '', 'status' => 'archived',
|
||
]);
|
||
|
||
self::assertSame(
|
||
[['type' => 'numbers', 'id' => 'gvid-5dec72fwsc', 'name' => 'Abstand Karte', 'action' => 'archived']],
|
||
VariableDiff::between($before, $archived)
|
||
);
|
||
self::assertSame(
|
||
[['type' => 'numbers', 'id' => 'gvid-5dec72fwsc', 'name' => 'Abstand', 'action' => 'archived',
|
||
'fields' => ['label']]],
|
||
VariableDiff::between($before, $archivedAndRenamed)
|
||
);
|
||
}
|
||
|
||
public function testReorderIsAChangeOfTheOrder(): void
|
||
{
|
||
$before = VariableBook::fromDivi(VariableBookTest::stored());
|
||
$after = $before->with('numbers', 'gvid-5dec72fwsc', [
|
||
'label' => 'Abstand Karte', 'value' => '2rem', 'order' => '1',
|
||
]);
|
||
|
||
self::assertSame(
|
||
[['type' => 'numbers', 'id' => 'gvid-5dec72fwsc', 'name' => 'Abstand Karte', 'action' => 'changed',
|
||
'fields' => ['order']]],
|
||
VariableDiff::between($before, $after)
|
||
);
|
||
}
|
||
|
||
public function testGradientsAreComparedLikeEveryOtherType(): void
|
||
{
|
||
$before = VariableBook::fromDivi(VariableBookTest::stored());
|
||
$after = $before->with('gradients', 'gvid-grad000001', [
|
||
'label' => 'Verlauf', 'value' => 'linear-gradient(blue, red)', 'order' => '1',
|
||
]);
|
||
|
||
self::assertSame(
|
||
[['type' => 'gradients', 'id' => 'gvid-grad000001', 'name' => 'Verlauf', 'action' => 'changed',
|
||
'fields' => ['value']]],
|
||
VariableDiff::between($before, $after)
|
||
);
|
||
}
|
||
|
||
public function testSummaryCountsTheActions(): void
|
||
{
|
||
self::assertSame(
|
||
'Variablen: 1 angelegt, 1 geändert, 1 archiviert.',
|
||
VariableDiff::summary([['action' => 'added'], ['action' => 'changed'], ['action' => 'archived']])
|
||
);
|
||
self::assertSame(
|
||
'Variablen: 2 geändert, 1 aktiviert.',
|
||
VariableDiff::summary([['action' => 'activated'], ['action' => 'changed'], ['action' => 'changed']])
|
||
);
|
||
self::assertSame('Variablen: keine Änderung.', VariableDiff::summary([]));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4c.2: RED-Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableDiffTest`
|
||
Expected: Abbruch mit `Error: Class "LIT\MCP\Integrations\Divi\Design\VariableDiff" not found`.
|
||
|
||
- [ ] **Step 4c.3: `VariableDiff` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Design/VariableDiff.php`. Reines PHP; sortiert nach Typ, dann ID.
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* What a variable change does (spec §4 step 4, "diff"): per variable added, changed (with the changed fields label,
|
||
* value, order, status), archived or activated (status switch; "fields" then lists label/value changes only – archiving
|
||
* clears the order on its own). lastUpdated is no change of its own. Variables are never removed (ruling V1), so a
|
||
* variable missing in $after produces no row. Sorted by type, then ID. Pure PHP.
|
||
*/
|
||
final class VariableDiff
|
||
{
|
||
private const COMPARED = ['label', 'value', 'order', 'status'];
|
||
|
||
/**
|
||
* @return list<array{type: string, id: string, name: string, action: string, fields?: list<string>}>
|
||
* action: added|changed|archived|activated
|
||
*/
|
||
public static function between(VariableBook $before, VariableBook $after): array
|
||
{
|
||
$new = $after->data();
|
||
ksort($new);
|
||
$rows = [];
|
||
foreach ($new as $type => $entries) {
|
||
ksort($entries);
|
||
foreach ($entries as $id => $is) {
|
||
$id = (string) $id;
|
||
$was = $before->entry($type, $id);
|
||
$row = ['type' => $type, 'id' => $id, 'name' => $is['label'] !== '' ? $is['label'] : $id];
|
||
if ($was === null) {
|
||
$rows[] = $row + ['action' => 'added'];
|
||
continue;
|
||
}
|
||
$action = self::action((string) $was['status'], (string) $is['status']);
|
||
// A status switch is the action itself; archiving clears the order, which is no change of its own.
|
||
$compared = $action === 'changed' ? self::COMPARED : array_slice(self::COMPARED, 0, 2);
|
||
$fields = array_values(array_filter(
|
||
$compared,
|
||
static fn (string $key): bool => $was[$key] !== $is[$key]
|
||
));
|
||
if ($fields !== [] || $action !== 'changed') {
|
||
$rows[] = $row + ['action' => $action] + ($fields === [] ? [] : ['fields' => $fields]);
|
||
}
|
||
}
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* One German sentence for the dry run and the change log (spec §4 step 6).
|
||
*
|
||
* @param list<array<string, mixed>> $diff
|
||
*/
|
||
public static function summary(array $diff): string
|
||
{
|
||
$count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff));
|
||
$words = [
|
||
'added' => 'angelegt', 'changed' => 'geändert', 'archived' => 'archiviert', 'activated' => 'aktiviert',
|
||
];
|
||
$parts = [];
|
||
foreach ($words as $action => $word) {
|
||
if (isset($count[$action])) {
|
||
$parts[] = $count[$action] . ' ' . $word;
|
||
}
|
||
}
|
||
|
||
return 'Variablen: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.';
|
||
}
|
||
|
||
private static function action(string $was, string $is): string
|
||
{
|
||
return match (true) {
|
||
$was === $is => 'changed',
|
||
$is === 'archived' => 'archived',
|
||
$was === 'archived' && $is === 'active' => 'activated',
|
||
default => 'changed',
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4c.4: GREEN-Lauf**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableDiffTest`
|
||
Expected: `OK (6 tests, 10 assertions)`.
|
||
|
||
- [ ] **Step 4c.5: Lint**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldung (PSR-12 + WPCS, Zeilen ≤ 120 Zeichen).
|
||
|
||
- [ ] **Step 4c.6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/VariableDiff.php tests/Unit/Integrations/Divi/Design/VariableDiffTest.php
|
||
git commit -m "feat: VariableDiff beschreibt Variablenänderungen (angelegt, geändert, archiviert, aktiviert) samt Zusammenfassung" \
|
||
-m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### Task 4 – Abschluss
|
||
|
||
- [ ] **Step 4.7: Gesamtlauf**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Unit-Tests grün (2930 + 68 neue = 2998), Lint ohne Meldung.
|
||
|
||
---
|
||
|
||
|
||
### Task 5: `ReferenceUsageQuery`, `PresetBook::referencing()` und `PresetBook::withVariableResolved()`
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/ReferenceUsageQuery.php`
|
||
- Modify: `src/Integrations/Divi/Design/PresetBook.php` (Konstante `SETTINGS`, `referencing()`, `withVariableResolved()`)
|
||
- Test: `tests/Unit/Integrations/Divi/Design/ReferenceUsageQueryTest.php` (neu)
|
||
- Test: `tests/Unit/Integrations/Divi/Design/PresetBookTest.php` (ergänzt: `storedWithReferences()` und vier Tests)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Library\UsageQuery` (`ESCAPED_LETTER_REGEX`), `BlockTree\Parser::parse()`, `BlockTree\Container::children()`,
|
||
`BlockTree\Block::attrs()/isOpaque()/rawOpen()`, `BlockTree\MalformedMarkup`, `Compose\VariableRef::nameOf()/settingsOf()/serialize()`.
|
||
- Produces:
|
||
- `final class ReferenceUsageQuery implements UsageQuery` mit
|
||
`public function __construct(array $ids, private Parser $parser = new Parser())` (`list<string>` gcid-/gvid-IDs; leer →
|
||
`InvalidArgumentException`), `public const ESCAPED_HYPHEN_REGEX`, `public static function mentions(mixed $value, array $ids): bool`
|
||
(rekursiv: enthält ein String in `$value` eine der IDs). `matches()`: Rohinhalt nennt eine ID (auch Divi-4-Shortcodes) **oder** die
|
||
dekodierten Attribute irgendeines Blocks (auch in globalen Modulen, rekursiv über den ganzen Baum) nennen eine ID **oder** ein Block hat
|
||
unlesbare Attribute **oder** der Parser lehnt das Markup ab → `true` (fail closed).
|
||
- `PresetBook::referencing(string $id): array` → `list<array{id: string, name: string, scope: string}>` (`scope` = Modul- bzw.
|
||
Gruppenname wie in `PresetDiff`).
|
||
- `PresetBook::withVariableResolved(string $id, string $value, bool $affixes = false): self` – ganze Verweise
|
||
`$variable({"type":"content","value":{"name":"<id>",…}})$` in `attrs`/`styleAttrs`/`renderAttrs` → `$value`; mit `$affixes`
|
||
`before . $value . after` aus den Einstellungen des Verweises (Task 8 übergibt `$variableType === 'strings'`, V9). Eingebettete
|
||
Verweise bleiben stehen.
|
||
|
||
**Warum kein `NodeIndex`:** `NodeIndex::walk()` steigt nicht in opake Blöcke (globale Module) und nicht in Nicht-Divi-Blöcke ab; die
|
||
Fundstellensuche muss fail closed jeden Block sehen. `ReferenceUsageQuery` läuft deshalb selbst rekursiv über `Container::children()` des
|
||
geparsten `Document`. Ein `\u002d` (JSON-kodierter Bindestrich) nach `gcid`/`gvid` findet `LIKE` nicht – dafür das zweite `REGEXP`
|
||
(Muster wie `ModuleUsageQuery` für den Schrägstrich).
|
||
|
||
#### 5a – `ReferenceUsageQuery`
|
||
|
||
- [ ] **Step 1: Failing test schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Design/ReferenceUsageQueryTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class ReferenceUsageQueryTest extends TestCase
|
||
{
|
||
/** A color reference as post_content holds it: quotes JSON-escaped (formats §2). */
|
||
private const COLOR_REF = '$variable({\u0022type\u0022:\u0022color\u0022,\u0022value\u0022:{\u0022name\u0022:'
|
||
. '\u0022gcid-holz000001\u0022,\u0022settings\u0022:{}}})$';
|
||
|
||
public function testCandidatesContainAnIdAnEscapedHyphenOrAnEscapedLetter(): void
|
||
{
|
||
$db = Mockery::mock(\wpdb::class);
|
||
$db->allows('esc_like')->andReturnUsing(static fn (string $text): string => $text);
|
||
$db->expects('prepare')
|
||
->with(
|
||
'(post_content LIKE %s OR post_content LIKE %s OR post_content REGEXP %s OR post_content REGEXP %s)',
|
||
'%gcid-holz000001%',
|
||
'%gvid-radius0001%',
|
||
'g[cv]id\\\\u002[dD]',
|
||
'\\\\u00[4-7][0-9a-fA-F]'
|
||
)
|
||
->andReturn('(prepared)');
|
||
|
||
$query = new ReferenceUsageQuery(['gcid-holz000001', 'gvid-radius0001', 'gcid-holz000001']);
|
||
|
||
self::assertSame('(prepared)', $query->candidateCondition($db));
|
||
}
|
||
|
||
public function testNeedsAnId(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
|
||
new ReferenceUsageQuery(['']);
|
||
}
|
||
|
||
public function testFindsAReferenceInTheAttributesOfAnyBlock(): void
|
||
{
|
||
$query = new ReferenceUsageQuery(['gcid-holz000001', 'gvid-radius0001']);
|
||
|
||
self::assertTrue($query->matches('<!-- wp:divi/text {"module":{"decoration":{"background":{"desktop":'
|
||
. '{"value":{"color":"' . self::COLOR_REF . '"}}}}}} /-->'));
|
||
self::assertTrue($query->matches('<!-- wp:divi/text {"x":"gvid-radius0001"} /-->'));
|
||
self::assertFalse($query->matches('<!-- wp:divi/text {"module":{"decoration":{"background":{"desktop":'
|
||
. '{"value":{"color":"#ffffff"}}}}}} /-->'));
|
||
self::assertFalse($query->matches('<!-- wp:divi/text {"x":"gcid-anders0001"} /-->'));
|
||
self::assertFalse($query->matches('<p>Keine Verweise</p>'));
|
||
}
|
||
|
||
public function testFindsAnIdWrittenWithJsonEscapesAlsoInsideGlobalModules(): void
|
||
{
|
||
$query = new ReferenceUsageQuery(['gcid-holz000001']);
|
||
|
||
self::assertTrue($query->matches('<!-- wp:divi/text {"x":"gcid\u002dholz000001"} /-->'));
|
||
self::assertTrue($query->matches('<!-- wp:divi/section {"globalModule":"12"} -->'
|
||
. '<!-- wp:divi/text {"x":"gcid\u002dholz000001"} /--><!-- /wp:divi/section -->'));
|
||
self::assertFalse($query->matches('<!-- wp:divi/text {"x":"gcid\u002danders0001"} /-->'));
|
||
}
|
||
|
||
public function testUnreadableMarkupAndDivi4ContentCount(): void
|
||
{
|
||
$query = new ReferenceUsageQuery(['gcid-holz000001']);
|
||
|
||
self::assertTrue($query->matches('<!-- wp:divi/section --><!-- wp:divi/text {"x":1} /-->'), 'never closed');
|
||
self::assertTrue($query->matches('<!-- wp:divi/text {"x":"gcid\u002dholz000001",} /-->'), 'attrs no JSON');
|
||
self::assertTrue($query->matches('[et_pb_text background_color="gcid-holz000001"]x[/et_pb_text]'), 'Divi 4');
|
||
}
|
||
|
||
public function testMentionsLooksIntoNestedStrings(): void
|
||
{
|
||
self::assertTrue(ReferenceUsageQuery::mentions(['a' => ['b' => 'x gvid-radius0001 y']], ['gvid-radius0001']));
|
||
self::assertFalse(ReferenceUsageQuery::mentions(['gvid-radius0001' => 1], ['gvid-radius0001']), 'keys');
|
||
self::assertFalse(ReferenceUsageQuery::mentions(12, ['gvid-radius0001']));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen – er schlägt fehl**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ReferenceUsageQueryTest`
|
||
Expected: FAIL mit `Error: Class "LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery" not found`.
|
||
|
||
- [ ] **Step 3: `ReferenceUsageQuery` implementieren**
|
||
|
||
`src/Integrations/Divi/Design/ReferenceUsageQuery.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Container;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The posts that reference any of the global colors or variables $ids (GlobalUsages::reportFor(), ruling V8). Divi
|
||
* writes a reference as $variable({"type":"color"|"content","value":{"name":"<id>",…}})$ into block attributes, the
|
||
* quotes JSON-escaped in post_content (formats §1, §2). Fail closed (ruling P23): candidates contain an ID, a
|
||
* JSON-escaped hyphen after gcid/gvid or an escaped letter; a candidate matches when its raw content names an ID
|
||
* (also Divi 4 shortcodes), when the decoded attributes of any block do – the whole tree, also inside global modules,
|
||
* which NodeIndex does not descend into –, when a block's attributes cannot be read or when the parser rejects the
|
||
* markup.
|
||
*/
|
||
final class ReferenceUsageQuery implements UsageQuery
|
||
{
|
||
/** MySQL REGEXP for "gcid"/"gvid" followed by a JSON-escaped hyphen ("\\u002d"), which LIKE does not find. */
|
||
public const ESCAPED_HYPHEN_REGEX = 'g[cv]id\\\\u002[dD]';
|
||
|
||
/** @var list<string> */
|
||
private array $ids;
|
||
|
||
/**
|
||
* @param list<string> $ids gcid-/gvid- IDs.
|
||
* @throws InvalidArgumentException Without an ID.
|
||
*/
|
||
public function __construct(array $ids, private Parser $parser = new Parser())
|
||
{
|
||
$ids = array_values(array_unique(array_filter(
|
||
$ids,
|
||
static fn (mixed $id): bool => is_string($id) && $id !== ''
|
||
)));
|
||
if ($ids === []) {
|
||
throw new InvalidArgumentException('ReferenceUsageQuery needs at least one ID.');
|
||
}
|
||
$this->ids = $ids;
|
||
}
|
||
|
||
/**
|
||
* Whether a string inside $value (nested arrays included, keys not) contains one of $ids.
|
||
*
|
||
* @param list<string> $ids
|
||
*/
|
||
public static function mentions(mixed $value, array $ids): bool
|
||
{
|
||
if (is_string($value)) {
|
||
foreach ($ids as $id) {
|
||
if (str_contains($value, $id)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
if (is_array($value)) {
|
||
foreach ($value as $inner) {
|
||
if (self::mentions($inner, $ids)) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
public function candidateCondition(\wpdb $db): string
|
||
{
|
||
$likes = array_map(static fn (string $id): string => '%' . $db->esc_like($id) . '%', $this->ids);
|
||
|
||
return (string) $db->prepare(
|
||
'(' . str_repeat('post_content LIKE %s OR ', count($likes))
|
||
. 'post_content REGEXP %s OR post_content REGEXP %s)',
|
||
...array_merge($likes, [self::ESCAPED_HYPHEN_REGEX, self::ESCAPED_LETTER_REGEX])
|
||
);
|
||
}
|
||
|
||
public function matches(string $content): bool
|
||
{
|
||
if (self::mentions($content, $this->ids)) {
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
return $this->inBlocks($this->parser->parse($content));
|
||
} catch (MalformedMarkup) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
private function inBlocks(Container $container): bool
|
||
{
|
||
foreach ($container->children() as $block) {
|
||
// The parser keeps attributes that are no JSON object as [] on an opaque block: unreadable, so it counts.
|
||
$unreadable = $block->isOpaque() && $block->attrs() === [] && str_contains((string) $block->rawOpen(), '{');
|
||
if ($unreadable || self::mentions($block->attrs(), $this->ids) || $this->inBlocks($block)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen – er besteht**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ReferenceUsageQueryTest`
|
||
Expected: PASS (6 Tests).
|
||
|
||
- [ ] **Step 5: Lint und Commit**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldungen.
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/ReferenceUsageQuery.php tests/Unit/Integrations/Divi/Design/ReferenceUsageQueryTest.php
|
||
git commit -m "feat: ReferenceUsageQuery findet Seiten, die globale Farben oder Variablen nennen (fail closed)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 5b – `PresetBook::referencing()` und `withVariableResolved()`
|
||
|
||
- [ ] **Step 6: Failing tests schreiben**
|
||
|
||
In `tests/Unit/Integrations/Divi/Design/PresetBookTest.php` den Import ergänzen (alphabetisch nach `InvalidArgumentException`):
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
```
|
||
|
||
und direkt nach `storedWithMarkup()` einfügen:
|
||
|
||
```php
|
||
/**
|
||
* stored() with references: CTA primär uses color gcid-holz000001, Doppelt variable gvid-slogan0001 (whole with
|
||
* before/after, and embedded in a longer value), Lesetext variable gvid-radius0001 in its styleAttrs.
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
public static function storedWithReferences(): array
|
||
{
|
||
$stored = self::stored();
|
||
$stored['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = [
|
||
'button' => ['decoration' => ['background' => ['desktop' => ['value' => [
|
||
'color' => VariableRef::serialize('color', 'gcid-holz000001', []),
|
||
]]]]],
|
||
];
|
||
$stored['module']['divi/button']['items']['bbbbbbbbbb']['attrs'] = [
|
||
'button' => ['innerContent' => ['desktop' => ['value' => [
|
||
'text' => VariableRef::serialize('content', 'gvid-slogan0001', ['before' => 'Ab ', 'after' => '!']),
|
||
]]]],
|
||
'module' => ['meta' => ['adminLabel' => ['desktop' => [
|
||
'value' => 'Vorher ' . VariableRef::serialize('content', 'gvid-slogan0001', []),
|
||
]]]],
|
||
];
|
||
$stored['group']['divi/font-body']['items']['gggggggggg']['styleAttrs'] = [
|
||
'font' => ['size' => VariableRef::serialize('content', 'gvid-radius0001', [])],
|
||
];
|
||
|
||
return $stored;
|
||
}
|
||
|
||
public function testReferencingListsThePresetsThatNameAColorOrVariable(): void
|
||
{
|
||
$book = PresetBook::fromDivi(self::storedWithReferences());
|
||
|
||
self::assertSame(
|
||
[['id' => 'aaaaaaaaaa', 'name' => 'CTA primär', 'scope' => 'divi/button']],
|
||
$book->referencing('gcid-holz000001')
|
||
);
|
||
self::assertSame(
|
||
[['id' => 'bbbbbbbbbb', 'name' => 'Doppelt', 'scope' => 'divi/button']],
|
||
$book->referencing('gvid-slogan0001')
|
||
);
|
||
self::assertSame(
|
||
[['id' => 'gggggggggg', 'name' => 'Lesetext', 'scope' => 'divi/font-body']],
|
||
$book->referencing('gvid-radius0001')
|
||
);
|
||
self::assertSame([], $book->referencing('gvid-fehlt00001'));
|
||
}
|
||
|
||
public function testWithVariableResolvedReplacesWholeReferencesOnly(): void
|
||
{
|
||
$stored = self::storedWithReferences();
|
||
$button = new PresetScope('module', 'divi/button');
|
||
$book = PresetBook::fromDivi($stored)->withVariableResolved('gvid-slogan0001', 'Holz mit Herz');
|
||
$attrs = $book->items($button)['bbbbbbbbbb']['attrs'];
|
||
|
||
self::assertSame('Holz mit Herz', $attrs['button']['innerContent']['desktop']['value']['text']);
|
||
self::assertSame(
|
||
$stored['module']['divi/button']['items']['bbbbbbbbbb']['attrs']['module'],
|
||
$attrs['module'],
|
||
'an embedded reference stays'
|
||
);
|
||
self::assertSame($stored['module']['divi/button']['items']['aaaaaaaaaa'], $book->items($button)['aaaaaaaaaa']);
|
||
}
|
||
|
||
/** Ruling V9: a strings variable keeps the before/after of the reference. */
|
||
public function testWithVariableResolvedFramesTheValueWithBeforeAndAfter(): void
|
||
{
|
||
$book = PresetBook::fromDivi(self::storedWithReferences())
|
||
->withVariableResolved('gvid-slogan0001', 'Holz mit Herz', true);
|
||
|
||
self::assertSame(
|
||
'Ab Holz mit Herz!',
|
||
$book->items(new PresetScope('module', 'divi/button'))['bbbbbbbbbb']['attrs']['button']['innerContent']
|
||
['desktop']['value']['text']
|
||
);
|
||
}
|
||
|
||
public function testWithVariableResolvedReachesStyleAttrsAndLeavesOtherPresetsAlone(): void
|
||
{
|
||
$stored = self::storedWithReferences();
|
||
$book = PresetBook::fromDivi($stored)->withVariableResolved('gvid-radius0001', '8px');
|
||
|
||
self::assertSame(
|
||
['font' => ['size' => '8px']],
|
||
$book->items(new PresetScope('group', 'divi/font-body'))['gggggggggg']['styleAttrs']
|
||
);
|
||
self::assertSame(
|
||
PresetBook::fromDivi($stored)->items(new PresetScope('module', 'divi/button')),
|
||
$book->items(new PresetScope('module', 'divi/button'))
|
||
);
|
||
self::assertSame(
|
||
PresetBook::fromDivi($stored)->data(),
|
||
PresetBook::fromDivi($stored)->withVariableResolved('gvid-fehlt00001', 'x')->data()
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Tests laufen lassen – sie schlagen fehl**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter PresetBookTest`
|
||
Expected: FAIL mit `Error: Call to undefined method LIT\MCP\Integrations\Divi\Design\PresetBook::referencing()` bzw.
|
||
`…PresetBook::withVariableResolved()` (4 Fehler); die übrigen PresetBook-Tests bestehen.
|
||
|
||
- [ ] **Step 8: `PresetBook` erweitern**
|
||
|
||
In `src/Integrations/Divi/Design/PresetBook.php`:
|
||
|
||
1. Import ergänzen (nach `use InvalidArgumentException;`):
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
```
|
||
|
||
2. Nach `private const ALPHABET = …;` einfügen:
|
||
|
||
```php
|
||
|
||
/** The keys of a preset that hold its settings (formats §3). */
|
||
private const SETTINGS = ['attrs', 'styleAttrs', 'renderAttrs'];
|
||
```
|
||
|
||
3. Direkt nach der Methode `withoutNestedRef()` einfügen:
|
||
|
||
```php
|
||
/**
|
||
* Presets whose settings (attrs, styleAttrs, renderAttrs) name the global color or variable $id – as a whole
|
||
* reference or inside a longer value (ruling V8: warnings of lit/divi-colors-set and lit/divi-variables-set).
|
||
*
|
||
* @return list<array{id: string, name: string, scope: string}> scope = module or group name
|
||
*/
|
||
public function referencing(string $id): array
|
||
{
|
||
$users = [];
|
||
foreach ($this->data as $records) {
|
||
foreach ($records as $scope => $record) {
|
||
foreach ($record['items'] as $presetId => $item) {
|
||
if (ReferenceUsageQuery::mentions(array_intersect_key($item, array_flip(self::SETTINGS)), [$id])) {
|
||
$users[] = [
|
||
'id' => (string) $presetId,
|
||
'name' => is_string($item['name'] ?? null) ? $item['name'] : (string) $presetId,
|
||
'scope' => (string) $scope,
|
||
];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return $users;
|
||
}
|
||
|
||
/**
|
||
* The inventory with every whole reference to global variable $id in the presets' settings replaced by $value
|
||
* (ruling V9, lit/divi-design-detach with variable). With $affixes the reference's own before/after settings frame
|
||
* the value (variables of type strings). References inside a longer value stay. Presets without a reference stay
|
||
* exactly as they are.
|
||
*/
|
||
public function withVariableResolved(string $id, string $value, bool $affixes = false): self
|
||
{
|
||
$data = $this->data;
|
||
foreach ($this->data as $kind => $records) {
|
||
foreach ($records as $name => $record) {
|
||
foreach ($record['items'] as $presetId => $item) {
|
||
foreach (self::SETTINGS as $key) {
|
||
if (! is_array($item[$key] ?? null)) {
|
||
continue;
|
||
}
|
||
$resolved = self::resolved($item[$key], $id, $value, $affixes);
|
||
if ($resolved !== $item[$key]) {
|
||
$data[$kind][$name]['items'][$presetId][$key] = $resolved;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return new self($data);
|
||
}
|
||
```
|
||
|
||
4. Direkt vor der Methode `hasMarkup()` einfügen:
|
||
|
||
```php
|
||
/**
|
||
* @param array<array-key, mixed> $settings
|
||
* @return array<array-key, mixed>
|
||
*/
|
||
private static function resolved(array $settings, string $id, string $value, bool $affixes): array
|
||
{
|
||
foreach ($settings as $key => $inner) {
|
||
if (is_array($inner)) {
|
||
$settings[$key] = self::resolved($inner, $id, $value, $affixes);
|
||
} elseif (is_string($inner) && VariableRef::nameOf($inner) === $id) {
|
||
$own = $affixes ? VariableRef::settingsOf($inner) : [];
|
||
$settings[$key] = self::text($own['before'] ?? '') . $value . self::text($own['after'] ?? '');
|
||
}
|
||
}
|
||
|
||
return $settings;
|
||
}
|
||
|
||
private static function text(mixed $value): string
|
||
{
|
||
return is_string($value) ? $value : '';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'PresetBookTest|ReferenceUsageQueryTest'`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 10: Lint und Commit**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldungen.
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/PresetBook.php tests/Unit/Integrations/Divi/Design/PresetBookTest.php
|
||
git commit -m "feat: PresetBook nennt Presets mit Verweisen auf Farben/Variablen und ersetzt Variablen-Verweise durch ihren Wert" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
- [ ] **Step 11: Gesamtlauf**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests grün, Lint sauber.
|
||
|
||
---
|
||
|
||
### Task 6: `ColorArea` und `lit/divi-colors-set`
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/ColorArea.php`
|
||
- Create: `src/Integrations/Divi/Design/DiviColorsSetAbility.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/ColorAreaTest.php` (neu, mit den Fixtures `stored()` und `adapter()`)
|
||
- Test: `tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php` (neu)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GlobalColorsAdapter::read()/save(array $entries, bool $replace)` (Task 2; in Tests per Mockery gemockt – die Klasse darf
|
||
darum nicht `final` sein, siehe ABWEICHUNGEN), `DesignRights::colors(DesignSystemWriter)` (Task 2), `DiviFeature::ColorWrite`
|
||
(Task 1), `ColorBook::fromDivi()/data()/rev()/entry()/label()/isCustomizer()/find()/with()/referencing()/cycle()/changedSince()/
|
||
newId(?Closure)/now()`, `ColorValue::check()/relative()`, `ColorDiff::between()/summary()` (Task 3), `ReferenceUsageQuery`,
|
||
`PresetBook::referencing()` (Task 5), `DesignChange::run()/inputProperties()/outputSchema()`, `DesignPlan`, `GlobalUsages::reportFor()`,
|
||
`UsageReport`, `DesignSystemWriter::presets()/canUseVisualBuilder()`, `VariableRef::serialize()`, `Errors`, `Annotations`.
|
||
- Produces:
|
||
- `final class ColorArea implements DesignArea { public function __construct(private GlobalColorsAdapter $colors) }` – `name()`
|
||
`'colors'`, `label()` `'Farben'`, `feature()` `DiviFeature::ColorWrite`; `write($raw, false)` speichert nur
|
||
`ColorBook::fromDivi($raw)->changedSince(<live gelesen>)` über `save($changed, false)` (nichts, wenn leer); `write($raw, true)` und
|
||
`restore($raw)` speichern den ganzen Bestand über `save($data, true)`; beide liefern den zurückgelesenen Bestand.
|
||
- `final class DiviColorsSetAbility implements Ability, RequiresDiviFeatures` mit
|
||
`__construct(DesignSystemWriter $writer, GlobalColorsAdapter $colors, DesignChange $change, GlobalUsages $usages, ?Closure $random = null)`
|
||
(Task 10 verdrahtet ohne `$random`); `public const MAX_ITEMS = 50`.
|
||
|
||
**Eingabe `items[]` von `lit/divi-colors-set`:** `id` (vorhandene Farbe: ID, Name, Alias `primary|secondary|heading|body|link` oder
|
||
Optionsname `accent_color …`, über `ColorBook::find()`), ohne `id` neue Farbe mit Pflichtfeldern `label` und `color`; `label` (1–100
|
||
Zeichen, frei – nicht schon Name, ID oder Alias einer anderen Farbe), `color` (V5), `relative` (nur mit Platzhalter, dann Pflicht),
|
||
`folder` (bis 100 Zeichen, nicht `customizer`), `status` (`active|inactive`). Hauptfarben: nur `color`, kein Platzhalter (Divi speichert sie
|
||
als festen Wert in `et_divi`). Ein Eintrag ohne Wirkung ändert nichts (kein neues `lastUpdated`).
|
||
|
||
#### 6a – `ColorArea`
|
||
|
||
- [ ] **Step 1: Failing test schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Design/ColorAreaTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Design\ColorArea;
|
||
use LIT\MCP\Integrations\Divi\Design\ColorBook;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class ColorAreaTest extends TestCase
|
||
{
|
||
/**
|
||
* GlobalColorsAdapter::read(): the main color primary, the color Holz dunkel and the relative color Sand built
|
||
* on it.
|
||
*
|
||
* @return array<string, array<string, mixed>>
|
||
*/
|
||
public static function stored(): array
|
||
{
|
||
return [
|
||
'gcid-primary-color' => ['color' => '#2ea3f2', 'folder' => 'customizer', 'label' => 'Primärfarbe',
|
||
'lastUpdated' => '2026-10-19T08:00:00.000Z', 'status' => 'active', 'usedInPosts' => []],
|
||
'gcid-holz000001' => ['color' => '#5b3a29', 'folder' => '', 'label' => 'Holz dunkel',
|
||
'lastUpdated' => '2026-10-01T10:00:00.000Z', 'status' => 'active', 'usedInPosts' => []],
|
||
'gcid-sand000002' => ['color' => VariableRef::serialize('color', 'gcid-holz000001', ['lightness' => 40]),
|
||
'folder' => '', 'label' => 'Sand', 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'status' => 'active',
|
||
'usedInPosts' => []],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* A GlobalColorsAdapter over $stored: save() merges (replace false) or replaces (true) like set_global_colors()
|
||
* and notes each call in $saves.
|
||
*
|
||
* @param array<string, array<string, mixed>> $stored
|
||
* @param list<array{0: array<string, mixed>, 1: bool}> $saves
|
||
*/
|
||
public static function adapter(array &$stored, array &$saves): GlobalColorsAdapter
|
||
{
|
||
$colors = Mockery::mock(GlobalColorsAdapter::class);
|
||
$colors->allows('read')->andReturnUsing(static function () use (&$stored): array {
|
||
return $stored;
|
||
});
|
||
$colors->allows('save')->andReturnUsing(
|
||
static function (array $entries, bool $replace) use (&$stored, &$saves): void {
|
||
$saves[] = [$entries, $replace];
|
||
$stored = $replace ? $entries : array_merge($stored, $entries);
|
||
}
|
||
);
|
||
|
||
return $colors;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, array<string, mixed>> $stored
|
||
* @param array<string, mixed> $fields
|
||
* @return array<string, array<string, mixed>>
|
||
*/
|
||
private static function changed(array $stored, string $id, array $fields): array
|
||
{
|
||
$stored[$id] = $fields + $stored[$id];
|
||
|
||
return $stored;
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private static function white(): array
|
||
{
|
||
return ['color' => '#ffffff', 'folder' => '', 'label' => 'Weiß', 'lastUpdated' => '2026-10-19T09:00:00.000Z',
|
||
'status' => 'active', 'usedInPosts' => []];
|
||
}
|
||
|
||
public function testAreaReadsThroughTheAdapter(): void
|
||
{
|
||
$stored = self::stored();
|
||
$saves = [];
|
||
$area = new ColorArea(self::adapter($stored, $saves));
|
||
|
||
self::assertSame(['colors', 'Farben'], [$area->name(), $area->label()]);
|
||
self::assertSame(DiviFeature::ColorWrite, $area->feature());
|
||
self::assertSame(ColorBook::fromDivi($stored)->data(), $area->raw());
|
||
self::assertSame(ColorBook::fromDivi($stored)->rev(), $area->rev($stored));
|
||
}
|
||
|
||
/** Ruling V4: only new and changed colors are merged; the unchanged main color is not written again. */
|
||
public function testWriteSavesOnlyNewAndChangedColorsAndReadsBack(): void
|
||
{
|
||
$stored = self::stored();
|
||
$saves = [];
|
||
$area = new ColorArea(self::adapter($stored, $saves));
|
||
$planned = self::changed(self::stored(), 'gcid-holz000001', ['color' => '#000000']);
|
||
$planned['gcid-neu0000003'] = self::white();
|
||
|
||
$saved = $area->write($planned, false);
|
||
|
||
self::assertCount(1, $saves);
|
||
self::assertFalse($saves[0][1], 'merge, never replace');
|
||
$keys = array_keys($saves[0][0]);
|
||
sort($keys);
|
||
self::assertSame(['gcid-holz000001', 'gcid-neu0000003'], $keys);
|
||
self::assertSame('#000000', $saved['gcid-holz000001']['color']);
|
||
self::assertSame(ColorBook::fromDivi($stored)->data(), $saved);
|
||
}
|
||
|
||
public function testWriteWithoutChangesSavesNothing(): void
|
||
{
|
||
$stored = self::stored();
|
||
$saves = [];
|
||
|
||
(new ColorArea(self::adapter($stored, $saves)))->write(self::stored(), false);
|
||
|
||
self::assertSame([], $saves);
|
||
}
|
||
|
||
/** Ruling V4: a restore (and a rollback) replaces all stored colors with the snapshot. */
|
||
public function testRestoreReplacesAllColorsWithTheSnapshot(): void
|
||
{
|
||
$stored = self::changed(self::stored(), 'gcid-holz000001', ['color' => '#000000']);
|
||
$stored['gcid-neu0000003'] = self::white();
|
||
$saves = [];
|
||
$area = new ColorArea(self::adapter($stored, $saves));
|
||
$snapshot = ColorBook::fromDivi(self::stored())->data();
|
||
|
||
$saved = $area->restore($snapshot);
|
||
|
||
self::assertSame([[$snapshot, true]], $saves);
|
||
self::assertSame($snapshot, $saved);
|
||
}
|
||
|
||
public function testVerifyInsistsOnTheSameColorsWithTheSameStatus(): void
|
||
{
|
||
$stored = self::stored();
|
||
$saves = [];
|
||
$area = new ColorArea(self::adapter($stored, $saves));
|
||
$expected = self::stored();
|
||
$missing = $expected;
|
||
unset($missing['gcid-sand000002']);
|
||
|
||
self::assertTrue($area->verify($expected, self::changed($expected, 'gcid-holz000001', ['color' => '#5B3A29'])));
|
||
self::assertFalse($area->verify($expected, $missing));
|
||
$inactive = self::changed($expected, 'gcid-holz000001', ['status' => 'inactive']);
|
||
self::assertFalse($area->verify($expected, $inactive));
|
||
}
|
||
|
||
public function testDifferencesNameTheColorsDiviChanged(): void
|
||
{
|
||
$stored = self::stored();
|
||
$saves = [];
|
||
$area = new ColorArea(self::adapter($stored, $saves));
|
||
$expected = self::stored();
|
||
|
||
self::assertSame([], $area->differences($expected, $expected));
|
||
self::assertSame(
|
||
['Divi hat die Farbe „Holz dunkel“ beim Speichern bereinigt.'],
|
||
$area->differences($expected, self::changed($expected, 'gcid-holz000001', ['color' => '#5B3A29']))
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen – er schlägt fehl**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorAreaTest`
|
||
Expected: FAIL mit `Error: Class "LIT\MCP\Integrations\Divi\Design\ColorArea" not found`.
|
||
|
||
- [ ] **Step 3: `ColorArea` implementieren**
|
||
|
||
`src/Integrations/Divi/Design/ColorArea.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The area "colors" (spec §7.1, rulings V3, V4): the global colors as ColorBook reads them through
|
||
* GlobalColorsAdapter (the five main colors from et_divi, their lastUpdated empty). A change writes only the new and
|
||
* changed colors – merged, the way Divi's builder saves –, compared with the inventory read right before; a restore
|
||
* (and a rollback) replaces all stored colors with the snapshot. Divi's sanitizing may change values: verify() only
|
||
* insists on the same colors with the same status, differences() names the colors whose values changed.
|
||
*/
|
||
final class ColorArea implements DesignArea
|
||
{
|
||
public function __construct(private GlobalColorsAdapter $colors)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'colors';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Farben';
|
||
}
|
||
|
||
public function feature(): DiviFeature
|
||
{
|
||
return DiviFeature::ColorWrite;
|
||
}
|
||
|
||
public function raw(): array
|
||
{
|
||
return ColorBook::fromDivi($this->colors->read())->data();
|
||
}
|
||
|
||
public function rev(array $raw): string
|
||
{
|
||
return ColorBook::fromDivi($raw)->rev();
|
||
}
|
||
|
||
public function write(array $raw, bool $mayRemove): array
|
||
{
|
||
$planned = ColorBook::fromDivi($raw);
|
||
if ($mayRemove) {
|
||
$this->colors->save($planned->data(), true);
|
||
|
||
return $this->raw();
|
||
}
|
||
|
||
$changed = $planned->changedSince(ColorBook::fromDivi($this->colors->read()));
|
||
if ($changed !== []) {
|
||
$this->colors->save($changed, false);
|
||
}
|
||
|
||
return $this->raw();
|
||
}
|
||
|
||
public function restore(array $raw): array
|
||
{
|
||
return $this->write($raw, true);
|
||
}
|
||
|
||
public function verify(array $expected, array $actual): bool
|
||
{
|
||
return self::statuses($expected) === self::statuses($actual);
|
||
}
|
||
|
||
public function differences(array $expected, array $actual): array
|
||
{
|
||
$warnings = [];
|
||
foreach (ColorDiff::between(ColorBook::fromDivi($expected), ColorBook::fromDivi($actual)) as $row) {
|
||
if ($row['action'] === 'changed') {
|
||
$warnings[] = sprintf('Divi hat die Farbe „%s“ beim Speichern bereinigt.', $row['name']);
|
||
}
|
||
}
|
||
|
||
return $warnings;
|
||
}
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $raw
|
||
* @return array<string, string> ID => status, sorted by ID
|
||
*/
|
||
private static function statuses(array $raw): array
|
||
{
|
||
$statuses = array_map(
|
||
static fn (array $entry): string => (string) $entry['status'],
|
||
ColorBook::fromDivi($raw)->data()
|
||
);
|
||
ksort($statuses);
|
||
|
||
return $statuses;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen – er besteht**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ColorAreaTest`
|
||
Expected: PASS (6 Tests).
|
||
|
||
- [ ] **Step 5: Lint und Commit**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldungen.
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/ColorArea.php tests/Unit/Integrations/Divi/Design/ColorAreaTest.php
|
||
git commit -m "feat: ColorArea schreibt geänderte globale Farben gemischt und stellt Snapshots ersetzend wieder her" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 6b – `lit/divi-colors-set`: Hülle, Pipeline, feste Farbwerte, Hauptfarben
|
||
|
||
- [ ] **Step 6: Failing tests schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php`:
|
||
|
||
```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\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');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Tests laufen lassen – sie schlagen fehl**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviColorsSetAbilityTest`
|
||
Expected: FAIL mit `Error: Class "LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility" not found`.
|
||
|
||
- [ ] **Step 8: `DiviColorsSetAbility` implementieren (feste Farbwerte)**
|
||
|
||
`src/Integrations/Divi/Design/DiviColorsSetAbility.php`:
|
||
|
||
```php
|
||
<?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;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DiviColorsSetAbilityTest|ColorAreaTest'`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 10: Lint und Commit**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldungen.
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DiviColorsSetAbility.php tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php
|
||
git commit -m "feat: lit/divi-colors-set legt globale Farben an, ändert und deaktiviert sie (Probelauf, design_rev, Snapshot)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 6c – Platzhalter, `relative`, Zyklen und Warnungen
|
||
|
||
- [ ] **Step 11: Failing tests ergänzen**
|
||
|
||
In `tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php` die Importe ergänzen (alphabetisch einsortieren):
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Design\ColorValue;
|
||
```
|
||
|
||
und vor der schließenden `}` der Klasse einfügen:
|
||
|
||
```php
|
||
/** 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']);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 12: Tests laufen lassen – sie schlagen fehl**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviColorsSetAbilityTest`
|
||
Expected: FAIL – `testAPlaceholderBecomesARelativeColorOfTheNamedColor` und `testAPlaceholderMayNameAColorOfTheSameCall` mit
|
||
`Failed asserting that two strings are identical.` (`'lit_confirm_required'` erwartet, `'lit_validation_failed'` erhalten: der
|
||
Platzhalter ist noch kein gültiger Farbwert), `testPlaceholderProblemsAreCollected` mit abweichenden Feldpfaden,
|
||
`testRelativeColorsMustNotFormACycle` mit `Failed asserting that two arrays are identical` (`items[0].color` statt `items`), die beiden
|
||
Warnungstests mit `Failed asserting that two arrays are identical` (keine Warnungen). Die übrigen Tests bestehen.
|
||
|
||
- [ ] **Step 13: `DiviColorsSetAbility` vervollständigen**
|
||
|
||
`src/Integrations/Divi/Design/DiviColorsSetAbility.php` vollständig ersetzen durch:
|
||
|
||
```php
|
||
<?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\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;
|
||
|
||
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, 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
|
||
{
|
||
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' => []];
|
||
|
||
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,
|
||
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);
|
||
}
|
||
$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);
|
||
}
|
||
|
||
$diff = ColorDiff::between($before, $book);
|
||
$touched = [];
|
||
foreach ($diff as $row) {
|
||
if ($row['action'] !== 'added') {
|
||
$touched[] = (string) $row['id'];
|
||
}
|
||
}
|
||
$touched = array_values(array_unique($touched));
|
||
$report = $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched));
|
||
|
||
return new DesignPlan(
|
||
$book->data(),
|
||
$diff,
|
||
ColorDiff::summary($diff),
|
||
$report,
|
||
$this->warnings($book, $diff, $report)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* $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($book, $item, $main),
|
||
};
|
||
} catch (InvalidArgumentException $e) {
|
||
$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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 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 Code RELATIVE when the problem is in relative.
|
||
*/
|
||
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);
|
||
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;
|
||
}
|
||
|
||
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
|
||
{
|
||
do {
|
||
$id = ColorBook::newId($this->random);
|
||
} while ($book->entry($id) !== null);
|
||
|
||
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 ? ', …' : '');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 14: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DiviColorsSetAbilityTest|ColorAreaTest'`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 15: Lint und Commit**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldungen.
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DiviColorsSetAbility.php tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php
|
||
git commit -m "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" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
- [ ] **Step 16: Gesamtlauf**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests grün, Lint sauber. (`DiviColorsSetAbility` ist noch nicht registriert; das folgt in Task 10.)
|
||
|
||
---
|
||
|
||
### Task 7: `VariableArea` und `lit/divi-variables-set`
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/VariableArea.php`
|
||
- Create: `src/Integrations/Divi/Design/DiviVariablesSetAbility.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/VariableAreaTest.php` (neu, mit den Fixtures `stored()` und `adapter()`)
|
||
- Test: `tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php` (neu)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GlobalVariablesAdapter::read()/save(array $data, array $expected)/canManage()` (Task 2; in Tests gemockt, darum nicht
|
||
`final`), `DesignRights::variables(DesignSystemWriter, GlobalVariablesAdapter)` (Task 2), `DiviFeature::VariableWrite` (Task 1),
|
||
`VariableValue::TYPES/check()`, `VariableBook::fromDivi()/data()/rev()/entry()/typeOf()/find()/with()/nextOrder()/restoredFrom()/
|
||
newId(?Closure)/now()`, `VariableDiff::between()/summary()` (Task 4), `ReferenceUsageQuery`, `PresetBook::referencing()` (Task 5),
|
||
`DesignChange`, `DesignPlan`, `GlobalUsages::reportFor()`, `UsageReport`, `DesignSystemWriter::presets()/canUseVisualBuilder()`.
|
||
- Produces:
|
||
- `final class VariableArea implements DesignArea { public function __construct(private GlobalVariablesAdapter $variables) }` –
|
||
`name()` `'variables'`, `label()` `'Variablen'`, `feature()` `DiviFeature::VariableWrite`; `write($raw, $mayRemove)` =
|
||
`save(VariableBook::fromDivi($raw)->data(), <live gelesen>)` (`$mayRemove` ohne Bedeutung: es wird nie entfernt, V1);
|
||
`restore($raw)` = `save(<live>->restoredFrom(<$raw>)->data(), <live>)` – auch beim Rollback der Pipeline bleiben so alle IDs erhalten.
|
||
- `final class DiviVariablesSetAbility implements Ability, RequiresDiviFeatures` mit
|
||
`__construct(DesignSystemWriter $writer, GlobalVariablesAdapter $variables, DesignChange $change, GlobalUsages $usages, ?Closure $random = null)`;
|
||
`public const MAX_ITEMS = 50`.
|
||
|
||
**Eingabe `items[]` von `lit/divi-variables-set`:** `id` (vorhandene Variable: ID oder Name über `VariableBook::find()`, mit `type`
|
||
eingeschränkt), ohne `id` neue Variable mit Pflichtfeldern `type` (`VariableValue::TYPES`), `label` und `value`; `label` (1–100 Zeichen,
|
||
frei über alle Typen), `value` (`VariableValue::check()`), `status` (`active|archived`). Archivieren setzt `order` auf `''`, Aktivieren hängt
|
||
die Variable mit `nextOrder()` hinten an; eine neue aktive Variable bekommt `nextOrder()`. Aktive Variablen brauchen einen Wert. Der Typ
|
||
einer vorhandenen Variable bleibt; Variablen vom Typ `gradients` (und anderen nicht anlegbaren Typen) lehnt das Tool ab. Kein `delete` (V1).
|
||
|
||
#### 7a – `VariableArea`
|
||
|
||
- [ ] **Step 1: Failing test schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Design/VariableAreaTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableArea;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableBook;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class VariableAreaTest extends TestCase
|
||
{
|
||
/**
|
||
* GlobalVariablesAdapter::read() (stored form, V7): Eckenradius (numbers), Slogan, the archived Alter Slogan
|
||
* and the archived empty Leer (strings), and Verlauf (gradients, only passed through).
|
||
*
|
||
* @return array<string, array<string, array<string, mixed>>>
|
||
*/
|
||
public static function stored(): array
|
||
{
|
||
$entry = static fn (string $type, string $id, string $label, string $value, string $order, string $status)
|
||
: array => ['id' => $id, 'label' => $label, 'value' => $value, 'order' => $order, 'status' => $status,
|
||
'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => $type];
|
||
|
||
return [
|
||
'numbers' => [
|
||
'gvid-radius0001' => $entry('numbers', 'gvid-radius0001', 'Eckenradius', '8px', '1', 'active'),
|
||
],
|
||
'strings' => [
|
||
'gvid-slogan0001' => $entry('strings', 'gvid-slogan0001', 'Slogan', 'Holz mit Herz', '1', 'active'),
|
||
'gvid-alt0000001' => $entry('strings', 'gvid-alt0000001', 'Alter Slogan', 'Früher', '', 'archived'),
|
||
'gvid-leer000001' => $entry('strings', 'gvid-leer000001', 'Leer', '', '', 'archived'),
|
||
],
|
||
'gradients' => [
|
||
'gvid-verlauf001' => $entry(
|
||
'gradients',
|
||
'gvid-verlauf001',
|
||
'Verlauf',
|
||
'linear-gradient(#fff, #000)',
|
||
'1',
|
||
'active'
|
||
),
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* A GlobalVariablesAdapter over $stored: save() replaces the inventory like set_global_variables() and notes each
|
||
* call (data, expected) in $saves.
|
||
*
|
||
* @param array<string, array<string, array<string, mixed>>> $stored
|
||
* @param list<array{0: array<string, mixed>, 1: array<string, mixed>}> $saves
|
||
*/
|
||
public static function adapter(array &$stored, array &$saves, bool $manage = true): GlobalVariablesAdapter
|
||
{
|
||
$variables = Mockery::mock(GlobalVariablesAdapter::class);
|
||
$variables->allows('canManage')->andReturn($manage);
|
||
$variables->allows('read')->andReturnUsing(static function () use (&$stored): array {
|
||
return $stored;
|
||
});
|
||
$variables->allows('save')->andReturnUsing(
|
||
static function (array $data, array $expected) use (&$stored, &$saves): void {
|
||
$saves[] = [$data, $expected];
|
||
$stored = $data;
|
||
}
|
||
);
|
||
|
||
return $variables;
|
||
}
|
||
|
||
public function testAreaReadsThroughTheAdapter(): void
|
||
{
|
||
$stored = self::stored();
|
||
$saves = [];
|
||
$area = new VariableArea(self::adapter($stored, $saves));
|
||
|
||
self::assertSame(['variables', 'Variablen'], [$area->name(), $area->label()]);
|
||
self::assertSame(DiviFeature::VariableWrite, $area->feature());
|
||
self::assertSame(VariableBook::fromDivi($stored)->data(), $area->raw());
|
||
self::assertSame(VariableBook::fromDivi($stored)->rev(), $area->rev($stored));
|
||
}
|
||
|
||
/** Ruling V7: the whole planned inventory, with the inventory read before for Divi's stale check. */
|
||
public function testWriteSavesThePlannedInventoryAgainstTheLiveOne(): void
|
||
{
|
||
$stored = self::stored();
|
||
$saves = [];
|
||
$area = new VariableArea(self::adapter($stored, $saves));
|
||
$planned = self::stored();
|
||
$planned['numbers']['gvid-radius0001']['value'] = '12px';
|
||
|
||
$saved = $area->write($planned, false);
|
||
|
||
self::assertSame([[VariableBook::fromDivi($planned)->data(), self::stored()]], $saves);
|
||
self::assertSame('12px', $saved['numbers']['gvid-radius0001']['value']);
|
||
}
|
||
|
||
/** Ruling V1b: Divi refuses an inventory without a stored ID – later variables stay, archived. */
|
||
public function testRestoreKeepsVariablesCreatedAfterTheSnapshotArchived(): void
|
||
{
|
||
$stored = self::stored();
|
||
$stored['numbers']['gvid-neu0000001'] = ['id' => 'gvid-neu0000001', 'label' => 'Neu', 'value' => '4px',
|
||
'order' => '2', 'status' => 'active', 'lastUpdated' => '2026-10-19T09:00:00.000Z',
|
||
'variableType' => 'numbers'];
|
||
$live = $stored;
|
||
$saves = [];
|
||
$area = new VariableArea(self::adapter($stored, $saves));
|
||
|
||
$area->restore(VariableBook::fromDivi(self::stored())->data());
|
||
|
||
self::assertCount(1, $saves);
|
||
self::assertSame(
|
||
VariableBook::fromDivi($live)->restoredFrom(VariableBook::fromDivi(self::stored()))->data(),
|
||
$saves[0][0]
|
||
);
|
||
self::assertSame($live, $saves[0][1]);
|
||
self::assertSame('archived', $saves[0][0]['numbers']['gvid-neu0000001']['status']);
|
||
}
|
||
|
||
public function testVerifyInsistsOnTheSameVariablesStatusAndValues(): void
|
||
{
|
||
$stored = self::stored();
|
||
$saves = [];
|
||
$area = new VariableArea(self::adapter($stored, $saves));
|
||
$expected = self::stored();
|
||
$sanitized = $expected;
|
||
$sanitized['strings']['gvid-slogan0001']['value'] = 'Holz mit Herz ';
|
||
$missing = $expected;
|
||
unset($missing['numbers']['gvid-radius0001']);
|
||
$archived = $expected;
|
||
$archived['numbers']['gvid-radius0001']['status'] = 'archived';
|
||
$emptied = $expected;
|
||
$emptied['numbers']['gvid-radius0001']['value'] = '';
|
||
|
||
self::assertTrue($area->verify($expected, $sanitized));
|
||
self::assertFalse($area->verify($expected, $missing));
|
||
self::assertFalse($area->verify($expected, $archived));
|
||
self::assertFalse($area->verify($expected, $emptied));
|
||
}
|
||
|
||
public function testDifferencesNameTheVariablesDiviChanged(): void
|
||
{
|
||
$stored = self::stored();
|
||
$saves = [];
|
||
$area = new VariableArea(self::adapter($stored, $saves));
|
||
$expected = self::stored();
|
||
$sanitized = $expected;
|
||
$sanitized['strings']['gvid-slogan0001']['value'] = 'Holz mit Herz ';
|
||
|
||
self::assertSame([], $area->differences($expected, $expected));
|
||
self::assertSame(
|
||
['Divi hat den Wert der Variablen „Slogan“ beim Speichern bereinigt.'],
|
||
$area->differences($expected, $sanitized)
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen – er schlägt fehl**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableAreaTest`
|
||
Expected: FAIL mit `Error: Class "LIT\MCP\Integrations\Divi\Design\VariableArea" not found`.
|
||
|
||
- [ ] **Step 3: `VariableArea` implementieren**
|
||
|
||
`src/Integrations/Divi/Design/VariableArea.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The area "variables" (spec §7.2, rulings V1, V7): the global variables in their stored form (VariableBook), saved
|
||
* as a whole through GlobalVariablesAdapter::save() together with the inventory read right before, which Divi checks
|
||
* for changes in between (global_variables_stale). Divi refuses an inventory that lacks a stored ID, so a restore keeps
|
||
* the variables created after the snapshot, archived (VariableBook::restoredFrom()) – also when the pipeline rolls back
|
||
* a write that went through. Without the variables_manager right Divi silently saves nothing: verify() compares the
|
||
* read-back (same variables, status and non-empty values), differences() names the values Divi sanitized.
|
||
*/
|
||
final class VariableArea implements DesignArea
|
||
{
|
||
public function __construct(private GlobalVariablesAdapter $variables)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'variables';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Variablen';
|
||
}
|
||
|
||
public function feature(): DiviFeature
|
||
{
|
||
return DiviFeature::VariableWrite;
|
||
}
|
||
|
||
public function raw(): array
|
||
{
|
||
return VariableBook::fromDivi($this->variables->read())->data();
|
||
}
|
||
|
||
public function rev(array $raw): string
|
||
{
|
||
return VariableBook::fromDivi($raw)->rev();
|
||
}
|
||
|
||
/** $mayRemove has no effect: variables are never removed (ruling V1). */
|
||
public function write(array $raw, bool $mayRemove): array
|
||
{
|
||
$this->variables->save(VariableBook::fromDivi($raw)->data(), $this->variables->read());
|
||
|
||
return $this->raw();
|
||
}
|
||
|
||
public function restore(array $raw): array
|
||
{
|
||
$live = $this->variables->read();
|
||
$this->variables->save(
|
||
VariableBook::fromDivi($live)->restoredFrom(VariableBook::fromDivi($raw))->data(),
|
||
$live
|
||
);
|
||
|
||
return $this->raw();
|
||
}
|
||
|
||
public function verify(array $expected, array $actual): bool
|
||
{
|
||
return self::shape($expected) === self::shape($actual);
|
||
}
|
||
|
||
public function differences(array $expected, array $actual): array
|
||
{
|
||
$warnings = [];
|
||
foreach (VariableDiff::between(VariableBook::fromDivi($expected), VariableBook::fromDivi($actual)) as $row) {
|
||
if ($row['action'] === 'changed') {
|
||
$warnings[] = sprintf('Divi hat den Wert der Variablen „%s“ beim Speichern bereinigt.', $row['name']);
|
||
}
|
||
}
|
||
|
||
return $warnings;
|
||
}
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $raw
|
||
* @return array<string, array{0: string, 1: bool}> "type/id" => [status, has a value], sorted
|
||
*/
|
||
private static function shape(array $raw): array
|
||
{
|
||
$shape = [];
|
||
foreach (VariableBook::fromDivi($raw)->data() as $type => $entries) {
|
||
foreach ($entries as $id => $entry) {
|
||
$value = $entry['value'] ?? '';
|
||
$shape[$type . '/' . $id] = [
|
||
(string) ($entry['status'] ?? ''),
|
||
is_scalar($value) && (string) $value !== '',
|
||
];
|
||
}
|
||
}
|
||
ksort($shape);
|
||
|
||
return $shape;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Test laufen lassen – er besteht**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableAreaTest`
|
||
Expected: PASS (5 Tests).
|
||
|
||
- [ ] **Step 5: Lint und Commit**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldungen.
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/VariableArea.php tests/Unit/Integrations/Divi/Design/VariableAreaTest.php
|
||
git commit -m "feat: VariableArea schreibt globale Variablen mit Divis Stale-Prüfung und behält beim Wiederherstellen spätere Variablen archiviert" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 7b – `lit/divi-variables-set`: Hülle, Pipeline, Typen, Werte, Archivieren
|
||
|
||
- [ ] **Step 6: Failing tests schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php`:
|
||
|
||
```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\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);
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Tests laufen lassen – sie schlagen fehl**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviVariablesSetAbilityTest`
|
||
Expected: FAIL mit `Error: Class "LIT\MCP\Integrations\Divi\Design\DiviVariablesSetAbility" not found`.
|
||
|
||
- [ ] **Step 8: `DiviVariablesSetAbility` implementieren**
|
||
|
||
`src/Integrations/Divi/Design/DiviVariablesSetAbility.php`:
|
||
|
||
```php
|
||
<?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) : '';
|
||
if ($clean === '' || 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;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DiviVariablesSetAbilityTest|VariableAreaTest'`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 10: Lint und Commit**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldungen.
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DiviVariablesSetAbility.php tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php
|
||
git commit -m "feat: lit/divi-variables-set legt globale Variablen an, ändert und archiviert sie (Probelauf, design_rev, Snapshot)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 7c – Warnungen: Verwendung in Presets, archivierte verwendete Variablen
|
||
|
||
- [ ] **Step 11: Failing tests ergänzen**
|
||
|
||
In `tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php` vor der schließenden `}` der Klasse einfügen:
|
||
|
||
```php
|
||
/** 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']);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 12: Tests laufen lassen – sie schlagen fehl**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviVariablesSetAbilityTest`
|
||
Expected: FAIL – `testArchivingAUsedVariableWarnsAndListsTheUsages` und `testChangingAVariableWarnsAboutThePresetsThatUseIt` mit
|
||
`Failed asserting that two arrays are identical.` (noch keine Warnungen); alle übrigen Tests bestehen.
|
||
|
||
- [ ] **Step 13: Warnungen ergänzen**
|
||
|
||
In `src/Integrations/Divi/Design/DiviVariablesSetAbility.php`:
|
||
|
||
1. Import ergänzen (nach `use LIT\MCP\Integrations\Divi\Library\GlobalUsages;`):
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
||
```
|
||
|
||
2. Klassen-Docblock, letzter Satz vorher:
|
||
|
||
```php
|
||
* 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).
|
||
*/
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
* 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) and warns about presets that use it.
|
||
*/
|
||
```
|
||
|
||
3. Nach der Konstante `NEW_ENTRY` einfügen:
|
||
|
||
```php
|
||
|
||
/** 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 archivierte Variablen; '
|
||
. 'archivierte Variablen bleiben dort als Verweis stehen. lit/divi-design-detach mit variable ersetzt die '
|
||
. 'Verweise vorher durch den Wert.';
|
||
```
|
||
|
||
4. Am Ende von `plan()` vorher:
|
||
|
||
```php
|
||
$touched = array_values(array_unique($touched));
|
||
|
||
return new DesignPlan(
|
||
$book->data(),
|
||
$diff,
|
||
VariableDiff::summary($diff),
|
||
$touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched))
|
||
);
|
||
}
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
$touched = array_values(array_unique($touched));
|
||
$report = $touched === [] ? null : $this->usages->reportFor(new ReferenceUsageQuery($touched));
|
||
|
||
return new DesignPlan(
|
||
$book->data(),
|
||
$diff,
|
||
VariableDiff::summary($diff),
|
||
$report,
|
||
$this->warnings($diff, $report)
|
||
);
|
||
}
|
||
```
|
||
|
||
5. Nach der Methode `newId()` (vor der schließenden `}` der Klasse) einfügen:
|
||
|
||
```php
|
||
|
||
/**
|
||
* Ruling V8: presets that use a changed, archived or activated variable are warnings, never a block; archiving a
|
||
* variable that posts still use is allowed (V1a) and points to lit/divi-design-detach.
|
||
*
|
||
* @param list<array<string, mixed>> $diff
|
||
* @return list<string>
|
||
*/
|
||
private function warnings(array $diff, ?UsageReport $report): array
|
||
{
|
||
$presets = null;
|
||
$warnings = [];
|
||
$archives = false;
|
||
foreach ($diff as $row) {
|
||
if ($row['action'] === 'added') {
|
||
continue;
|
||
}
|
||
$archived = $row['action'] === 'archived';
|
||
$archives = $archives || $archived;
|
||
$presets ??= PresetBook::fromDivi($this->writer->presets());
|
||
$users = array_column($presets->referencing((string) $row['id']), 'name');
|
||
if ($users !== []) {
|
||
$warnings[] = sprintf(
|
||
$archived
|
||
? 'Variable „%s“ wird archiviert, aber noch von Presets verwendet: %s.'
|
||
: 'Die Änderung an Variable „%s“ wirkt auch auf die Presets %s.',
|
||
$row['name'],
|
||
self::names($users)
|
||
);
|
||
}
|
||
}
|
||
if ($archives && $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 ? ', …' : '');
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 14: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DiviVariablesSetAbilityTest|VariableAreaTest'`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 15: Lint und Commit**
|
||
|
||
Run: `composer lint`
|
||
Expected: keine Meldungen.
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DiviVariablesSetAbility.php tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php
|
||
git commit -m "feat: lit/divi-variables-set warnt vor Verwendungen in Presets und beim Archivieren verwendeter Variablen" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
- [ ] **Step 16: Gesamtlauf**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests grün, Lint sauber. (`DiviVariablesSetAbility` ist noch nicht registriert; das folgt in Task 10.)
|
||
|
||
---
|
||
|
||
|
||
### Task 8: `DetachTarget`, Variablen-Detach (`VariableDetachRewrite`, `lit/divi-design-detach` mit `variable`)
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/DetachTarget.php`, `src/Integrations/Divi/Design/VariableDetachRewrite.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/DetachTargetTest.php`, `tests/Unit/Integrations/Divi/Design/VariableDetachRewriteTest.php`
|
||
- Modify: `src/Integrations/Divi/Design/DiviDesignDetachAbility.php`, `src/Integrations/Divi/Design/DetachRewrite.php` (nur `mentions()` öffentlich), `src/Integrations/Divi/DiviIntegration.php` (nur der Konstruktoraufruf des Detach-Tools)
|
||
- Modify (Tests): `tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php`, `tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php` (ein `inputCases()`-Fall), `tests/Integration/divi-presets-assign-smoke.sh` (Erwartung für eine unbekannte Variable)
|
||
|
||
**Interfaces:**
|
||
- Consumes (Schnittstellenvertrag, Tasks 1–5): `DiviFeature::VariableWrite`; `GlobalVariablesAdapter::read()`, `::canManage()` (über `DesignRights::variables()`); `DesignRights::variables(DesignSystemWriter, GlobalVariablesAdapter)`; `VariableBook::fromDivi()`, `->find(string $needle): ?array{type, id}` (wirft `InvalidArgumentException` bei mehrdeutigem Label), `->entry(string $type, string $id): ?array`; `new ReferenceUsageQuery(list<string> $ids)`; `PresetBook::referencing(string $id): list<array{id, name, scope}>`, `->withVariableResolved(string $id, string $value): self`. Vorhanden: `VariableRef::nameOf()/settingsOf()/contains()/serialize()` (`Compose/VariableRef.php`; `nameOf()` liefert den Namen nur für einen *ganzen* Verweis vom Typ `content` – Farbverweise `type: color` bleiben damit außen vor), `PageRewriter::preview()/apply()`, `TreeRewrite` (ruft `notes()` nur für geänderte Blöcke), `DesignChange::run()`, `PresetArea`, `PresetDiff`, `PageRewriteReport`, `PostIdsInput`, `DetachRewrite`.
|
||
- Produces:
|
||
- `final class DetachTarget { public function __construct(public readonly string $subject, public readonly ?array $postIds, public readonly UsageQuery $query, public readonly Closure $rewrite, public readonly ?Closure $presets, public readonly string $nestedIn, public readonly array $notes, public readonly string $doneHint); public function change(): string; public function finished(array $skipped, int $hidden): bool; }` – `$presets` ist `Closure(PresetBook): PresetBook` (der Preset-Schritt) oder `null`.
|
||
- `final class VariableDetachRewrite implements NodeRewrite { public function __construct(string $id, string $value, string $variableType); public static function replacement(string $reference, string $value, string $variableType): string; }`
|
||
- `DetachRewrite::mentions(Block $block, string $id): bool` wird `public static` (vorher `private`).
|
||
- **Neue Konstruktor-Signatur** `DiviDesignDetachAbility::__construct(DesignSystemWriter $writer, GlobalVariablesAdapter $variables, DesignChange $change, GlobalUsages $usages, PageRewriter $pages, ?Closure $unfiltered = null)`; `diviFeatures($input)` = bisherige Liste, mit `variable` zusätzlich `VariableWrite`.
|
||
|
||
Regeln (V9, R10, Spec §10):
|
||
- `variable` (Name oder ID, ohne Beachtung der Groß-/Kleinschreibung über `VariableBook::find()`) und `preset` schließen sich aus (`validation_failed` an `variable`). Eine `gcid-`-ID ist eine Farbe → `unsupported` mit Hinweis „nur deaktivieren (lit/divi-colors-set)“; eine unbekannte Variable → `not_found` mit demselben Hinweis (deckt auch Farb-Labels ab). Ob die Variable aktiv oder archiviert ist, spielt keine Rolle; die Variable selbst wird nicht geschrieben. Ein Wert, der kein String ist (Typ `gradients`, falls Divi ihn als Objekt speichert), → `unsupported`.
|
||
- Ersetzt wird jeder Attributwert, der *genau ein* Verweis `$variable({"type":"content","value":{"name":"<gvid>",…}})$` ist, durch den aktuellen Wert; bei `strings` mit `before`/`after` aus den Einstellungen des Verweises (`before . Wert . after`). Eingebettete Verweise bleiben; `notes()` meldet sie je geändertem Modul. Farbverweise, andere Variablen, dynamische Inhalte und Nicht-Divi-Blöcke bleiben unberührt. Globale Instanzen, deren Attribute die ID nennen, stehen in `skipped` (`concernsInstance()` wie `DetachRewrite`).
|
||
- Ohne `post_ids` und wenn `PresetBook::referencing()` Presets nennt, läuft der Preset-Schritt (`withVariableResolved()`) wie bei Gruppen-Presets zuerst, mit Snapshot `presets`, in **einem** `confirm_required` (pages + diff + design_rev). Rechte: `DesignRights::presets()` (der Preset-Schritt schreibt Presets) und `DesignRights::variables()`; `edit_post` je Live-Fundstelle prüft der `PageRewriter` (P8/P8a) bzw. der Plan des Preset-Schritts.
|
||
- Fertig-Hinweis: „Jetzt kann lit/divi-variables-set die Variable archivieren (status: archived).“ (wie `DELETE_HINT` nur ohne `post_ids`, ohne `skipped`, ohne verborgene Fundstellen und ohne `remaining`).
|
||
|
||
#### 8a – `DetachTarget` statt des Arrays (Refactor, Verhalten unverändert)
|
||
|
||
- [ ] **Step 1: Absicherung: bestehende Detach-Tests grün**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignDetachAbilityTest|DetachRewriteTest/'`
|
||
Expected: `OK (31 tests, …)` (26 + 5). Diese Tests bleiben in 8a unverändert.
|
||
|
||
- [ ] **Step 2: Failing Test `DetachTargetTest` schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Design/DetachTargetTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\DetachRewrite;
|
||
use LIT\MCP\Integrations\Divi\Design\DetachTarget;
|
||
use LIT\MCP\Integrations\Divi\Design\NodeRewrite;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class DetachTargetTest extends TestCase
|
||
{
|
||
/** @param list<int>|null $postIds */
|
||
private static function target(?array $postIds): DetachTarget
|
||
{
|
||
$scope = new PresetScope('module', 'divi/button');
|
||
|
||
return new DetachTarget(
|
||
'Modul-Preset divi/button „CTA primär“',
|
||
$postIds,
|
||
new PresetUsageQuery([['scope' => $scope, 'id' => 'aaaaaaaaaa', 'default' => false]]),
|
||
static fn (): NodeRewrite => new DetachRewrite($scope, 'aaaaaaaaaa'),
|
||
null,
|
||
'anderen Presets',
|
||
[],
|
||
'Jetzt kann lit/divi-presets-delete das Preset löschen.'
|
||
);
|
||
}
|
||
|
||
public function testTheChangeNamesTheSubject(): void
|
||
{
|
||
self::assertSame('Entkoppeln von Modul-Preset divi/button „CTA primär“', self::target(null)->change());
|
||
}
|
||
|
||
public function testFinishedOnlyWithoutPostIdsSkippedOrHiddenUsages(): void
|
||
{
|
||
self::assertTrue(self::target(null)->finished([], 0));
|
||
self::assertFalse(self::target([12])->finished([], 0), 'post_ids leave other usages');
|
||
self::assertFalse(self::target(null)->finished([['post_id' => 12, 'reason' => 'x']], 0));
|
||
self::assertFalse(self::target(null)->finished([], 3));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DetachTargetTest`
|
||
Expected: FAIL – `Error: Class "LIT\MCP\Integrations\Divi\Design\DetachTarget" not found`.
|
||
|
||
- [ ] **Step 4: `DetachTarget` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Design/DetachTarget.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* What lit/divi-design-detach removes (spec §6.8, rulings R10, V9), resolved on the live inventory: the usage query
|
||
* and a fresh NodeRewrite per post for the PageRewriter and – when presets nest the target and no post_ids narrow the
|
||
* call – the preset step, i.e. how the preset inventory changes (DesignChange, snapshot "presets"). Pure PHP.
|
||
*/
|
||
final class DetachTarget
|
||
{
|
||
/**
|
||
* @param string $subject The target in messages: 'Variable „Eckenradius“'.
|
||
* @param list<int>|null $postIds Only these posts; null = every usage.
|
||
* @param Closure(): NodeRewrite $rewrite A fresh strategy per post.
|
||
* @param (Closure(PresetBook): PresetBook)|null $presets The preset step; null = none.
|
||
* @param string $nestedIn Where the preset step removes references (messages).
|
||
* @param list<string> $notes Warnings of every answer.
|
||
* @param string $doneHint The next step once nothing references the target.
|
||
*/
|
||
public function __construct(
|
||
public readonly string $subject,
|
||
public readonly ?array $postIds,
|
||
public readonly UsageQuery $query,
|
||
public readonly Closure $rewrite,
|
||
public readonly ?Closure $presets,
|
||
public readonly string $nestedIn,
|
||
public readonly array $notes,
|
||
public readonly string $doneHint
|
||
) {
|
||
}
|
||
|
||
/** The change in messages and in the change log of every saved post. */
|
||
public function change(): string
|
||
{
|
||
return 'Entkoppeln von ' . $this->subject;
|
||
}
|
||
|
||
/**
|
||
* Whether nothing is left to detach: all usages, nothing skipped, and no usage this account may not read.
|
||
*
|
||
* @param list<array<string, mixed>> $skipped
|
||
*/
|
||
public function finished(array $skipped, int $hidden): bool
|
||
{
|
||
return $this->postIds === null && $skipped === [] && $hidden === 0;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: GREEN**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DetachTargetTest`
|
||
Expected: `OK (2 tests, 5 assertions)`.
|
||
|
||
- [ ] **Step 6: `DiviDesignDetachAbility` auf `DetachTarget` umbauen**
|
||
|
||
`target()` wird `presetTarget(): DetachTarget|WP_Error`; `dryRun()`, `presetDryRun()`, `nestedPlan()`, `confirmed()` und `afterPresets()` nehmen das Wertobjekt; `finished()` und `notes()` der Ability entfallen (jetzt `DetachTarget::finished()` bzw. `$target->notes`). Der Preset-Schritt ist die Closure `$target->presets` (`withoutNestedRef($id)`), `null` ohne Verschachtelung oder mit `post_ids` – genau die bisherige Bedingung `nested`. Texte bleiben Wort für Wort gleich: `subject` = `<Scope-Label> „<Name>“`, `nestedIn` = `anderen Presets`. Der Rest der Datei (Kopf bis `execute()`, `firstPageError()`, `invalid()`) bleibt. Ganze Datei nach dem Umbau:
|
||
|
||
```php
|
||
<?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\Abilities\ToolResult;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||
use LIT\MCP\Log;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/divi-design-detach (spec §6.8, ruling R10 – presets only): removes every explicit reference to one preset –
|
||
* from the modulePreset/groupPreset stacks of pages, library entries and Theme Builder layouts (DetachRewrite through
|
||
* the PageRewriter) and, for a group preset without post_ids, from the groupPresets of other presets (DesignChange,
|
||
* snapshot "presets"). Modules fall back to the next preset of their stack or the default. The dry run is ONE
|
||
* confirm_required with the pages and, when other presets nest it, the diff and design_rev of the preset step.
|
||
* Confirmed, the preset step runs first – its plan carries the page usages, so a blocked live post refuses before
|
||
* anything is written (P8a) – then at most PageRewriter::PAGE_LIMIT pages. A default preset keeps working implicitly
|
||
* (Divi applies it); only explicit references go. Variables follow with Etappe 4c.
|
||
*/
|
||
final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
|
||
{
|
||
private const VARIABLES = 'Variablen folgen mit Etappe 4c.';
|
||
|
||
private const DELETE_HINT = 'Jetzt kann lit/divi-presets-delete das Preset löschen.';
|
||
|
||
private const DEFAULT_NOTE = '„%s“ ist das Standard-Preset von %s: Divi wendet es weiter auf Module ohne eigenes '
|
||
. 'Preset an; entfernt werden nur die ausdrücklichen Verweise.';
|
||
|
||
/** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */
|
||
public function __construct(
|
||
private DesignSystemWriter $writer,
|
||
private DesignChange $change,
|
||
private GlobalUsages $usages,
|
||
private PageRewriter $pages,
|
||
private ?Closure $unfiltered = null
|
||
) {
|
||
$this->unfiltered ??= DesignRights::unfilteredHtml();
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/divi-design-detach';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Divi-Preset-Verweise entfernen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) aus '
|
||
. 'Seiten, Bibliothekseinträgen und Theme-Builder-Layouts und – bei Gruppen-Presets – aus anderen Presets; '
|
||
. 'Module fallen auf das nächste Preset im Stapel bzw. das Standard-Preset zurück. post_ids beschränkt auf '
|
||
. 'diese Seiten (Verweise in Presets bleiben dann). Zuerst Probelauf (pages; bei Verweisen in Presets diff '
|
||
. 'und design_rev), nach Rückfrage beim Nutzer confirm: true (mit design_rev, wenn der Probelauf eine '
|
||
. 'nennt); je Aufruf höchstens ' . PageRewriter::PAGE_LIMIT . ' Seiten, remaining nennt den Rest. Mit '
|
||
. 'post_ids können nur die genannten Seiten das Schreiben blockieren und die Obergrenze für sehr viele '
|
||
. 'Seiten gilt nicht; eine genannte veröffentlichte, private oder geplante Seite ohne Bearbeitungsrecht '
|
||
. 'verweigert den ganzen Aufruf (forbidden, blocked_ids), eine genannte andere Seite ohne '
|
||
. 'Bearbeitungsrecht steht in skipped (bei mehr als 2000 Live-Fundstellen entfällt die Vorabprüfung, '
|
||
. 'dann auch eine genannte Live-Seite). Danach kann lit/divi-presets-delete das Preset löschen. '
|
||
. self::VARIABLES;
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'preset' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'module' => ['type' => 'string'],
|
||
'group' => ['type' => 'string'],
|
||
'preset' => ['type' => 'string', 'description' => 'Name oder ID des Presets.'],
|
||
],
|
||
'required' => ['preset'],
|
||
],
|
||
'variable' => ['type' => 'string', 'description' => self::VARIABLES],
|
||
'post_ids' => PostIdsInput::schema(),
|
||
] + DesignChange::inputProperties(),
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return PageRewriteReport::outputSchema([
|
||
'diff' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'design_rev' => ['type' => ['string', 'null']],
|
||
]);
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::write(true);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Like lit/divi-presets-assign (it rewrites every kind of post), plus the preset step that removes references
|
||
* nested in other presets (PresetWrite).
|
||
*/
|
||
public function diviFeatures(array $input): array
|
||
{
|
||
return [
|
||
DiviFeature::PageRead,
|
||
DiviFeature::PageWrite,
|
||
DiviFeature::Catalog,
|
||
DiviFeature::DesignRead,
|
||
DiviFeature::PresetWrite,
|
||
DiviFeature::LibraryRead,
|
||
DiviFeature::LibraryWrite,
|
||
DiviFeature::ThemeBuilderWrite,
|
||
DiviFeature::Roles,
|
||
];
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return DesignRights::presets($this->writer) ?? true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = DesignRights::presets($this->writer);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
if (array_key_exists('variable', $input)) {
|
||
return Errors::unsupported(
|
||
'Verweise auf Variablen entfernt LIT MCP noch nicht; nichts wurde geändert.',
|
||
self::VARIABLES . ' Bis dahin nur Presets angeben (preset).'
|
||
);
|
||
}
|
||
$target = $this->presetTarget($input);
|
||
if ($target instanceof WP_Error) {
|
||
return $target;
|
||
}
|
||
|
||
return ($input['confirm'] ?? false) === true ? $this->confirmed($input, $target) : $this->dryRun($target);
|
||
}
|
||
|
||
/**
|
||
* The preset to detach, resolved on the live inventory.
|
||
*
|
||
* @param array<string, mixed> $input
|
||
*/
|
||
private function presetTarget(array $input): DetachTarget|WP_Error
|
||
{
|
||
$preset = $input['preset'] ?? null;
|
||
if (! is_array($preset)) {
|
||
return self::invalid(['preset' => 'Objekt mit module oder group und preset (Name oder ID) erwartet']);
|
||
}
|
||
try {
|
||
$postIds = PostIdsInput::parse($input['post_ids'] ?? null);
|
||
} catch (InvalidArgumentException $e) {
|
||
return self::invalid(['post_ids' => $e->getMessage()]);
|
||
}
|
||
try {
|
||
$scope = PresetScope::fromInput($preset, 'preset');
|
||
} catch (InvalidArgumentException $e) {
|
||
[$path, $problem] = explode(': ', $e->getMessage(), 2) + [1 => ''];
|
||
|
||
return self::invalid([$path => $problem]);
|
||
}
|
||
$name = is_string($preset['preset'] ?? null) ? trim($preset['preset']) : '';
|
||
if ($name === '') {
|
||
return self::invalid(['preset.preset' => 'Name oder ID des Presets erwartet']);
|
||
}
|
||
|
||
$book = PresetBook::fromDivi($this->writer->presets());
|
||
try {
|
||
$found = $book->find($scope, $name);
|
||
} catch (InvalidArgumentException $e) {
|
||
return self::invalid(['preset.preset' => $e->getMessage()]);
|
||
}
|
||
if ($found === null) {
|
||
return Errors::notFound(
|
||
sprintf('Unbekanntes %s „%s“; nichts wurde geändert.', $scope->label(), $name),
|
||
'Vorhandene Presets mit Namen und IDs liefert lit/divi-design-system (direkt: lit-divi-design-system).'
|
||
);
|
||
}
|
||
|
||
$id = (string) $found['id'];
|
||
$label = is_string($found['name'] ?? null) ? $found['name'] : $id;
|
||
$nested = $postIds === null && $book->nestedUsers($id) !== [];
|
||
|
||
return new DetachTarget(
|
||
sprintf('%s „%s“', $scope->label(), $label),
|
||
$postIds,
|
||
// Explicit references only (R10), also for a default preset: no withGroups(), implicit uses stay.
|
||
new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => false]]),
|
||
static fn (): NodeRewrite => new DetachRewrite($scope, $id),
|
||
$nested ? static fn (PresetBook $presets): PresetBook => $presets->withoutNestedRef($id) : null,
|
||
'anderen Presets',
|
||
$book->defaultId($scope) === $id ? [sprintf(self::DEFAULT_NOTE, $label, $scope->name)] : [],
|
||
self::DELETE_HINT
|
||
);
|
||
}
|
||
|
||
/**
|
||
* ONE confirm_required with the pages and, when presets nest the target, the preset step's diff and
|
||
* design_rev; a normal result when nothing references it any more.
|
||
*
|
||
* @return array<string, mixed>|WP_Error
|
||
*/
|
||
private function dryRun(DetachTarget $target): array|WP_Error
|
||
{
|
||
$preview = $this->pages->preview($target->query, $target->rewrite, $target->postIds);
|
||
if ($preview instanceof WP_Error) {
|
||
return $preview;
|
||
}
|
||
$presets = $target->presets !== null ? $this->presetDryRun($target) : [];
|
||
if ($presets instanceof WP_Error) {
|
||
return $presets;
|
||
}
|
||
|
||
if ($presets === [] && PageRewriteReport::isEmpty($preview)) {
|
||
$summary = sprintf('Keine Verweise auf %s gefunden – nichts zu tun.', $target->subject);
|
||
$done = $target->finished($preview['skipped'], $preview['hidden_count']);
|
||
|
||
return PageRewriteReport::nothingToDo(
|
||
$summary . ($done ? ' ' . $target->doneHint : ''),
|
||
$preview,
|
||
$target->notes
|
||
);
|
||
}
|
||
|
||
$message = PageRewriteReport::dryRunMessage($preview, $target->change());
|
||
if ($presets !== []) {
|
||
$message .= sprintf(
|
||
' Zuerst entfernt der Aufruf die Verweise in %d %s (diff, mit Snapshot); dafür die design_rev mit '
|
||
. 'confirm: true mitsenden.',
|
||
count($presets['diff']),
|
||
$target->nestedIn
|
||
);
|
||
}
|
||
|
||
return Errors::confirmRequired($message, $preview + $presets + ['warnings' => $target->notes]);
|
||
}
|
||
|
||
/**
|
||
* The preset step's dry run through DesignChange::run() without confirm: its confirm_required carries diff and
|
||
* design_rev; a result without error means no preset nests the target any more.
|
||
*
|
||
* @return array{diff: list<array<string, mixed>>, design_rev: string}|array{}|WP_Error
|
||
*/
|
||
private function presetDryRun(DetachTarget $target): array|WP_Error
|
||
{
|
||
$result = $this->change->run($this->name(), new PresetArea($this->writer), [], $this->nestedPlan($target));
|
||
if (! $result instanceof WP_Error) {
|
||
return [];
|
||
}
|
||
if (Errors::codeOf($result) !== 'confirm_required') {
|
||
return $result;
|
||
}
|
||
$data = (array) $result->get_error_data();
|
||
|
||
return ['diff' => $data['diff'] ?? [], 'design_rev' => (string) ($data['design_rev'] ?? '')];
|
||
}
|
||
|
||
/** @return Closure(array<array-key, mixed>): (DesignPlan|WP_Error) */
|
||
private function nestedPlan(DetachTarget $target): Closure
|
||
{
|
||
return function (array $raw) use ($target): DesignPlan|WP_Error {
|
||
$before = PresetBook::fromDivi($raw);
|
||
$after = $target->presets === null ? $before : ($target->presets)($before);
|
||
$diff = PresetDiff::between($before, $after);
|
||
if ($diff === []) {
|
||
return new DesignPlan($after->data(), [], PresetDiff::summary([]));
|
||
}
|
||
// The whole inventory is saved: HTML/CSS of any preset would be stripped without unfiltered_html (I-2).
|
||
$refusal = DesignRights::markup($this->unfiltered, $after->markupOutside([]));
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
// The page usages ride along: a blocked live post refuses before the presets are written (P8a).
|
||
return new DesignPlan(
|
||
$after->data(),
|
||
$diff,
|
||
PresetDiff::summary($diff),
|
||
$this->usages->reportFor($target->query)
|
||
);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Preset step first (stops on any error – nothing written), then at most PAGE_LIMIT pages.
|
||
*
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>|WP_Error
|
||
* @throws WriteFailed When the preset step fails, or the first page fails without a preset step.
|
||
* @throws DiviLocked Likewise.
|
||
*/
|
||
private function confirmed(array $input, DetachTarget $target): array|WP_Error
|
||
{
|
||
$presets = null;
|
||
if ($target->presets !== null) {
|
||
$presets = $this->change->run(
|
||
$this->name(),
|
||
new PresetArea($this->writer),
|
||
$input,
|
||
$this->nestedPlan($target)
|
||
);
|
||
if ($presets instanceof WP_Error) {
|
||
return $presets;
|
||
}
|
||
}
|
||
$wrotePresets = $presets !== null && $presets['diff'] !== [];
|
||
|
||
try {
|
||
$pages = $this->pages->apply(
|
||
$this->name(),
|
||
$target->query,
|
||
$target->rewrite,
|
||
$target->postIds,
|
||
$target->change()
|
||
);
|
||
} catch (WriteFailed | DiviLocked $e) {
|
||
// Thrown when the first page fails: the registrar would answer "nothing was saved" – untrue after the
|
||
// preset step. Without one, its mapping stays as for every Divi write.
|
||
if (! $wrotePresets) {
|
||
throw $e;
|
||
}
|
||
Log::failure($this->name(), $e);
|
||
|
||
return self::afterPresets(self::firstPageError($e), $presets, $target);
|
||
}
|
||
if ($pages instanceof WP_Error) {
|
||
return $wrotePresets ? self::afterPresets($pages, $presets, $target) : $pages;
|
||
}
|
||
|
||
$summary = PageRewriteReport::savedSummary($pages, $target->change());
|
||
if ($wrotePresets) {
|
||
$summary = $presets['summary'] . ' ' . $summary;
|
||
}
|
||
if ($pages['remaining'] === 0 && $target->finished($pages['skipped'], $pages['hidden_count'])) {
|
||
$summary .= ' ' . $target->doneHint;
|
||
}
|
||
|
||
return ToolResult::ok($summary)
|
||
->snapshotId($presets['snapshot_id'] ?? null)
|
||
->logId($presets['log_id'] ?? null)
|
||
->warnings(array_merge($presets['warnings'] ?? [], $pages['warnings'], $target->notes))
|
||
->with('saved', $pages['saved'])
|
||
->with('remaining', $pages['remaining'])
|
||
->with('skipped', $pages['skipped'])
|
||
->with('hidden_count', $pages['hidden_count'])
|
||
->with('diff', $presets['diff'] ?? [])
|
||
->with('design_rev', $presets['design_rev'] ?? null)
|
||
->toArray();
|
||
}
|
||
|
||
/**
|
||
* The client error for a failure on the first page, mapped like AbilityRegistrar::errorFor(), but a clean rollback
|
||
* no longer claims that nothing was saved (afterPresets() adds what the preset step changed).
|
||
*/
|
||
private static function firstPageError(WriteFailed|DiviLocked $e): WP_Error
|
||
{
|
||
if ($e instanceof WriteFailed && ! $e->rolledBack) {
|
||
return $e->toError();
|
||
}
|
||
$locked = DiviLocked::in($e);
|
||
if ($locked !== null) {
|
||
return $locked->toError();
|
||
}
|
||
|
||
return $e->withDetails([], state: 'Keine Seite wurde gespeichert.')->toError();
|
||
}
|
||
|
||
/**
|
||
* A page error after the preset step wrote: the client learns that the nested references are gone already.
|
||
*
|
||
* @param array<string, mixed> $presets The preset step's ToolResult.
|
||
*/
|
||
private static function afterPresets(WP_Error $error, array $presets, DetachTarget $target): WP_Error
|
||
{
|
||
$data = (array) $error->get_error_data();
|
||
|
||
return Errors::make(
|
||
Errors::codeOf($error),
|
||
Errors::clientMessage($error) . sprintf(
|
||
' Die Verweise in %s sind bereits entfernt (Snapshot %s).',
|
||
$target->nestedIn,
|
||
(string) $presets['snapshot_id']
|
||
),
|
||
(string) ($data['hint'] ?? ''),
|
||
(int) ($data['status'] ?? 500),
|
||
array_diff_key($data, ['status' => true, 'hint' => true])
|
||
+ ['snapshot_id' => $presets['snapshot_id'], 'diff' => $presets['diff']]
|
||
);
|
||
}
|
||
|
||
/** @param array<string, string> $errors */
|
||
private static function invalid(array $errors): WP_Error
|
||
{
|
||
return Errors::invalidInput('Ungültige Angaben; nichts wurde geändert.', $errors);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: GREEN mit unveränderten Tests, Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignDetachAbilityTest|DetachRewriteTest|DetachTargetTest/' && composer lint`
|
||
Expected: `OK (33 tests, …)`, phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
Commit wie in den Global Constraints:
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DetachTarget.php \
|
||
tests/Unit/Integrations/Divi/Design/DetachTargetTest.php \
|
||
src/Integrations/Divi/Design/DiviDesignDetachAbility.php
|
||
git commit -m "refactor: DetachTarget ersetzt das Array in DiviDesignDetachAbility::target(); Verhalten unverändert" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 8b – `VariableDetachRewrite` (V9)
|
||
|
||
- [ ] **Step 9: Failing Test `VariableDetachRewriteTest` schreiben**
|
||
|
||
`ref()` ist öffentlich, weil `DiviDesignDetachAbilityTest` (8c) dieselben Verweise baut. Datei `tests/Unit/Integrations/Divi/Design/VariableDetachRewriteTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Json;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableDetachRewrite;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class VariableDetachRewriteTest extends TestCase
|
||
{
|
||
private const RADIUS = 'gvid-radius0001';
|
||
private const CLAIM = 'gvid-claim00001';
|
||
|
||
/** A whole reference to global variable $id, as the Divi Builder stores it. */
|
||
public static function ref(string $id, array $settings = []): string
|
||
{
|
||
return VariableRef::serialize('content', $id, $settings);
|
||
}
|
||
|
||
/** A breakpoint map with one desktop value. */
|
||
private static function desktop(mixed $value): array
|
||
{
|
||
return ['desktop' => ['value' => $value]];
|
||
}
|
||
|
||
/**
|
||
* @param list<array{0: string, 1: array<string, mixed>}> $blocks name and attributes, in the order of the column
|
||
* @return NodeIndex Blocks below s0.r0.c0 are m0, m1, … (g0, … for instances, x0, … for other blocks).
|
||
*/
|
||
private static function index(array $blocks): NodeIndex
|
||
{
|
||
$inner = '';
|
||
foreach ($blocks as [$name, $attrs]) {
|
||
$inner .= sprintf('<!-- wp:%s %s /-->', $name, Json::encode($attrs));
|
||
}
|
||
|
||
return NodeIndex::build((new Parser())->parse(
|
||
'<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->' . $inner
|
||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->'
|
||
));
|
||
}
|
||
|
||
private static function node(NodeIndex $index, string $id): Block
|
||
{
|
||
$block = $index->find('s0.r0.c0.' . $id);
|
||
self::assertNotNull($block);
|
||
|
||
return $block;
|
||
}
|
||
|
||
public function testReplacesWholeNumberReferencesAtAnyDepth(): void
|
||
{
|
||
$index = self::index([['divi/button', [
|
||
'button' => ['decoration' => ['border' => self::desktop([
|
||
'radius' => ['topLeft' => self::ref(self::RADIUS), 'topRight' => self::ref(self::RADIUS)],
|
||
])]],
|
||
'module' => ['decoration' => ['spacing' => self::desktop(['padding' => ['top' => '10px']])]],
|
||
]]]);
|
||
|
||
$attrs = (new VariableDetachRewrite(self::RADIUS, '8px', 'numbers'))->rewrite(self::node($index, 'm0'));
|
||
|
||
self::assertSame(
|
||
['topLeft' => '8px', 'topRight' => '8px'],
|
||
$attrs['button']['decoration']['border']['desktop']['value']['radius'] ?? null
|
||
);
|
||
self::assertSame('10px', $attrs['module']['decoration']['spacing']['desktop']['value']['padding']['top']);
|
||
}
|
||
|
||
public function testAStringsVariableKeepsBeforeAndAfterOfTheReference(): void
|
||
{
|
||
$index = self::index([
|
||
['divi/text', ['content' => ['innerContent' => self::desktop(
|
||
self::ref(self::CLAIM, ['before' => 'Jetzt: ', 'after' => '!'])
|
||
)]]],
|
||
['divi/text', ['content' => ['innerContent' => self::desktop(self::ref(self::CLAIM))]]],
|
||
]);
|
||
$rewrite = new VariableDetachRewrite(self::CLAIM, 'Gut beraten', 'strings');
|
||
|
||
self::assertSame(
|
||
'Jetzt: Gut beraten!',
|
||
$rewrite->rewrite(self::node($index, 'm0'))['content']['innerContent']['desktop']['value'] ?? null
|
||
);
|
||
self::assertSame(
|
||
'Gut beraten',
|
||
$rewrite->rewrite(self::node($index, 'm1'))['content']['innerContent']['desktop']['value'] ?? null
|
||
);
|
||
}
|
||
|
||
public function testOtherTypesIgnoreBeforeAndAfter(): void
|
||
{
|
||
self::assertSame(
|
||
'Open Sans',
|
||
VariableDetachRewrite::replacement(self::ref('gvid-font000001', ['before' => 'x']), 'Open Sans', 'fonts')
|
||
);
|
||
self::assertSame('a1b', VariableDetachRewrite::replacement(self::ref(self::CLAIM, [
|
||
'before' => 'a', 'after' => 'b',
|
||
]), '1', 'strings'));
|
||
}
|
||
|
||
public function testFontsAndOtherReferencesStayUntouched(): void
|
||
{
|
||
$color = VariableRef::serialize('color', 'gcid-primary-color', []);
|
||
$dynamic = VariableRef::serialize('content', 'post_title', []);
|
||
$index = self::index([
|
||
['divi/heading', [
|
||
'title' => ['decoration' => ['font' => ['font' => self::desktop([
|
||
'family' => self::ref('gvid-font000001'),
|
||
'color' => $color,
|
||
])]]],
|
||
'content' => ['innerContent' => self::desktop($dynamic)],
|
||
]],
|
||
['divi/text', ['content' => ['innerContent' => self::desktop(self::ref(self::RADIUS))]]],
|
||
['core/paragraph', ['content' => self::ref('gvid-font000001')]],
|
||
]);
|
||
$rewrite = new VariableDetachRewrite('gvid-font000001', 'Open Sans', 'fonts');
|
||
|
||
$attrs = $rewrite->rewrite(self::node($index, 'm0'));
|
||
self::assertSame(
|
||
['family' => 'Open Sans', 'color' => $color],
|
||
$attrs['title']['decoration']['font']['font']['desktop']['value'] ?? null
|
||
);
|
||
self::assertSame($dynamic, $attrs['content']['innerContent']['desktop']['value']);
|
||
self::assertNull($rewrite->rewrite(self::node($index, 'm1')), 'another variable');
|
||
self::assertNull($rewrite->rewrite(self::node($index, 'x0')), 'not a Divi block');
|
||
}
|
||
|
||
public function testEmbeddedReferencesStayAndAreNoted(): void
|
||
{
|
||
$embedded = 'Ab ' . self::ref(self::RADIUS) . ' Rundung';
|
||
$index = self::index([
|
||
['divi/text', [
|
||
'content' => ['innerContent' => self::desktop($embedded)],
|
||
'module' => ['decoration' => ['border' => self::desktop([
|
||
'radius' => ['topLeft' => self::ref(self::RADIUS)],
|
||
])]],
|
||
]],
|
||
['divi/text', ['content' => ['innerContent' => self::desktop($embedded)]]],
|
||
]);
|
||
$rewrite = new VariableDetachRewrite(self::RADIUS, '8px', 'numbers');
|
||
$first = self::node($index, 'm0');
|
||
|
||
$attrs = $rewrite->rewrite($first);
|
||
self::assertSame($embedded, $attrs['content']['innerContent']['desktop']['value'] ?? null);
|
||
$first->setAttrs((array) $attrs);
|
||
self::assertSame(
|
||
['s0.r0.c0.m0: Die Variable steht hier eingebettet in einem längeren Text; dieser Verweis bleibt stehen – '
|
||
. 'im Divi-Builder ersetzen.'],
|
||
$rewrite->notes($first, 's0.r0.c0.m0')
|
||
);
|
||
self::assertNull($rewrite->rewrite(self::node($index, 'm1')), 'only embedded: nothing to replace');
|
||
self::assertSame(
|
||
[],
|
||
(new VariableDetachRewrite(self::CLAIM, 'x', 'strings'))->notes($first, 's0.r0.c0.m0'),
|
||
'another variable is not noted'
|
||
);
|
||
}
|
||
|
||
public function testInstancesThatMentionTheIdConcernTheRewrite(): void
|
||
{
|
||
$index = self::index([
|
||
['divi/global-layout', ['globalModule' => '1058', 'blockName' => 'divi/text', 'localAttrs' => [
|
||
'content' => ['innerContent' => self::desktop(self::ref(self::RADIUS))],
|
||
]]],
|
||
['divi/global-layout', ['globalModule' => '1059', 'blockName' => 'divi/text']],
|
||
]);
|
||
$rewrite = new VariableDetachRewrite(self::RADIUS, '8px', 'numbers');
|
||
|
||
self::assertTrue($rewrite->concernsInstance(self::node($index, 'g0')));
|
||
self::assertFalse($rewrite->concernsInstance(self::node($index, 'g1')));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 10: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter VariableDetachRewriteTest`
|
||
Expected: FAIL – `Error: Class "LIT\MCP\Integrations\Divi\Design\VariableDetachRewrite" not found` (6 Fehler).
|
||
|
||
- [ ] **Step 11: `DetachRewrite::mentions()` öffentlich machen**
|
||
|
||
`src/Integrations/Divi/Design/DetachRewrite.php`, Zeilen 127–128 – vorher:
|
||
|
||
```php
|
||
/** Whether the attributes of $block or of any block below mention $id (fail closed on unencodable values). */
|
||
private static function mentions(Block $block, string $id): bool
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
/**
|
||
* Whether the attributes of $block or of any block below mention $id (fail closed on unencodable values); also
|
||
* VariableDetachRewrite::concernsInstance().
|
||
*/
|
||
public static function mentions(Block $block, string $id): bool
|
||
```
|
||
|
||
- [ ] **Step 12: `VariableDetachRewrite` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Design/VariableDetachRewrite.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Replaces the references to global variable $id in the attributes of Divi blocks by its value (lit/divi-design-detach
|
||
* with variable, ruling V9): every attribute value that is exactly one reference $variable({"type":"content",
|
||
* "value":{"name":"<id>",…}})$ becomes the value – for a strings variable with the reference's own before/after
|
||
* around it. References embedded in a longer text stay (Divi resolves them inside the text; cutting them out would
|
||
* change the text) and are named in notes(). Other references – colors, other variables, dynamic content – stay
|
||
* untouched. Pure PHP.
|
||
*/
|
||
final class VariableDetachRewrite implements NodeRewrite
|
||
{
|
||
private const EMBEDDED = '%s: Die Variable steht hier eingebettet in einem längeren Text; dieser Verweis bleibt '
|
||
. 'stehen – im Divi-Builder ersetzen.';
|
||
|
||
public function __construct(private string $id, private string $value, private string $variableType)
|
||
{
|
||
}
|
||
|
||
/**
|
||
* What one whole reference to a variable of $variableType becomes: its value, for strings with the reference's
|
||
* before/after settings around it.
|
||
*/
|
||
public static function replacement(string $reference, string $value, string $variableType): string
|
||
{
|
||
if ($variableType !== 'strings') {
|
||
return $value;
|
||
}
|
||
$settings = VariableRef::settingsOf($reference);
|
||
$before = is_string($settings['before'] ?? null) ? $settings['before'] : '';
|
||
$after = is_string($settings['after'] ?? null) ? $settings['after'] : '';
|
||
|
||
return $before . $value . $after;
|
||
}
|
||
|
||
public function rewrite(Block $block): ?array
|
||
{
|
||
if (! $block->isDivi()) {
|
||
return null;
|
||
}
|
||
|
||
$changed = false;
|
||
$attrs = $this->resolved($block->attrs(), $changed);
|
||
|
||
return $changed && is_array($attrs) ? $attrs : null;
|
||
}
|
||
|
||
public function notes(Block $block, string $nodeId): array
|
||
{
|
||
return $this->embeds($block->attrs()) ? [sprintf(self::EMBEDDED, $nodeId)] : [];
|
||
}
|
||
|
||
public function concernsInstance(Block $instance): bool
|
||
{
|
||
return DetachRewrite::mentions($instance, $this->id);
|
||
}
|
||
|
||
/** $value with every whole reference to the variable replaced; $changed tells whether one was. */
|
||
private function resolved(mixed $value, bool &$changed): mixed
|
||
{
|
||
if (is_string($value)) {
|
||
if (VariableRef::nameOf($value) !== $this->id) {
|
||
return $value;
|
||
}
|
||
$changed = true;
|
||
|
||
return self::replacement($value, $this->value, $this->variableType);
|
||
}
|
||
if (! is_array($value)) {
|
||
return $value;
|
||
}
|
||
|
||
foreach ($value as $key => $item) {
|
||
$value[$key] = $this->resolved($item, $changed);
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
/** Whether a string below $value names the variable inside a longer text. */
|
||
private function embeds(mixed $value): bool
|
||
{
|
||
if (is_string($value)) {
|
||
return VariableRef::contains($value) && str_contains($value, '"' . $this->id . '"');
|
||
}
|
||
if (! is_array($value)) {
|
||
return false;
|
||
}
|
||
|
||
foreach ($value as $item) {
|
||
if ($this->embeds($item)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 13: GREEN, Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/VariableDetachRewriteTest|DetachRewriteTest/' && composer lint`
|
||
Expected: `OK (11 tests, …)`, phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 14: Commit**
|
||
|
||
Commit wie in den Global Constraints:
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/VariableDetachRewrite.php \
|
||
src/Integrations/Divi/Design/DetachRewrite.php \
|
||
tests/Unit/Integrations/Divi/Design/VariableDetachRewriteTest.php
|
||
git commit -m "feat: VariableDetachRewrite ersetzt ganze Verweise auf eine Variable durch ihren Wert (V9)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 8c – `lit/divi-design-detach` mit `variable`
|
||
|
||
- [ ] **Step 15: Tests anpassen und ergänzen**
|
||
|
||
`tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php` – die Hilfe `ability()` reicht einen `GlobalVariablesAdapter` durch, `testVariablesFollowWithStage4c()` entfällt, alle übrigen Preset-Tests bleiben unverändert. Die Variablen-Fixture ist Divis Speicherform (`VariableBook::fromDivi()`); der Matcher `referencing()` prüft die Abfrage nur über `UsageQuery::matches()` (Vertrag Task 5: Inhalt, dessen Attribute die ID nennen).
|
||
|
||
*Importe (nach `use LIT\MCP\Abilities\WriteFailed;`)* – vorher:
|
||
|
||
```php
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
```
|
||
|
||
*Importe (nach `DiviDesignDetachAbility`)* – vorher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility;
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
|
||
```
|
||
|
||
*Importe (nach `PresetUsageQuery`)* – vorher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
|
||
use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery;
|
||
```
|
||
|
||
*Importe (nach `SnapshotStore`)* – vorher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableDetachRewrite;
|
||
```
|
||
|
||
*Konstanten (Zeile 34)* – vorher:
|
||
|
||
```php
|
||
private const LESETEXT = ['group' => 'font-body', 'preset' => 'Lesetext'];
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
private const LESETEXT = ['group' => 'font-body', 'preset' => 'Lesetext'];
|
||
|
||
private const RADIUS = 'gvid-radius0001';
|
||
|
||
private const CLAIM = 'gvid-claim00001';
|
||
```
|
||
|
||
*Hilfe `ability()` (Zeilen 50–58)* – vorher:
|
||
|
||
```php
|
||
?UsageReport $report = null,
|
||
bool $unfiltered = true
|
||
): DiviDesignDetachAbility {
|
||
$usages = Mockery::mock(GlobalUsages::class);
|
||
$usages->allows('reportFor')->andReturn($report ?? new UsageReport(0, [], 0, 0, [], 0, false));
|
||
|
||
return new DiviDesignDetachAbility(
|
||
$writer ?? self::readOnlyWriter(),
|
||
new DesignChange
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
?UsageReport $report = null,
|
||
bool $unfiltered = true,
|
||
?GlobalVariablesAdapter $variables = null
|
||
): DiviDesignDetachAbility {
|
||
$usages = Mockery::mock(GlobalUsages::class);
|
||
$usages->allows('reportFor')->andReturn($report ?? new UsageReport(0, [], 0, 0, [], 0, false));
|
||
|
||
return new DiviDesignDetachAbility(
|
||
$writer ?? self::readOnlyWriter(),
|
||
$variables ?? self::variableAdapter(),
|
||
new DesignChange
|
||
```
|
||
|
||
*neue Hilfen vor `untouched()` (Zeile 80)* – vorher:
|
||
|
||
```php
|
||
private static function untouched(): PageRewriter&MockInterface
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
/** Reads variables() (the Variablen-Verwaltung is allowed unless $manage is false), never saves. */
|
||
private static function variableAdapter(bool $manage = true): GlobalVariablesAdapter&MockInterface
|
||
{
|
||
$variables = Mockery::mock(GlobalVariablesAdapter::class);
|
||
$variables->allows('read')->andReturn(self::variables());
|
||
$variables->allows('canManage')->andReturn($manage);
|
||
$variables->expects('save')->never();
|
||
|
||
return $variables;
|
||
}
|
||
|
||
/** @return array<string, array<string, array<string, mixed>>> Global variables in Divi's stored form. */
|
||
private static function variables(): array
|
||
{
|
||
return [
|
||
'numbers' => [self::RADIUS => [
|
||
'id' => self::RADIUS, 'label' => 'Eckenradius', 'value' => '8px', 'order' => '1',
|
||
'status' => 'active', 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => 'numbers',
|
||
]],
|
||
// Archived or not does not matter for a detach (V9).
|
||
'strings' => [self::CLAIM => [
|
||
'id' => self::CLAIM, 'label' => 'Claim', 'value' => 'Gut beraten', 'order' => '',
|
||
'status' => 'archived', 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => 'strings',
|
||
]],
|
||
];
|
||
}
|
||
|
||
/** @return array<string, mixed> PresetBookTest::stored() with the radius variable in preset "CTA primär". */
|
||
private static function storedWithVariable(): array
|
||
{
|
||
$stored = PresetBookTest::stored();
|
||
$radius = ['topLeft' => VariableDetachRewriteTest::ref(self::RADIUS)];
|
||
$stored['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = ['button' => ['decoration' => [
|
||
'border' => ['desktop' => ['value' => ['radius' => $radius]]],
|
||
]]];
|
||
|
||
return $stored;
|
||
}
|
||
|
||
/** Matches the reference query of a preview()/apply() call: it finds $id and nothing else. */
|
||
private static function referencing(string $id): Closure
|
||
{
|
||
return static fn (ReferenceUsageQuery $query): bool => $query->matches(
|
||
'<!-- wp:divi/text {"x":"' . $id . '"} /-->'
|
||
) && ! $query->matches('<!-- wp:divi/text {"x":"gvid-andere0001"} /-->');
|
||
}
|
||
|
||
/** Matches a VariableDetachRewrite strategy that turns a whole reference to $id into $expected. */
|
||
private static function resolving(string $id, string $expected, array $settings = []): Closure
|
||
{
|
||
return static function (Closure $rewrite) use ($id, $expected, $settings): bool {
|
||
$strategy = $rewrite();
|
||
$block = Block::create('divi/text', ['x' => VariableDetachRewriteTest::ref($id, $settings)]);
|
||
|
||
return $strategy instanceof VariableDetachRewrite && $strategy->rewrite($block) === ['x' => $expected];
|
||
};
|
||
}
|
||
|
||
private static function untouched(): PageRewriter&MockInterface
|
||
```
|
||
|
||
*`testVariablesFollowWithStage4c()` (Zeilen 113–121) wird durch die Variablen-Tests ersetzt* – vorher:
|
||
|
||
```php
|
||
public function testVariablesFollowWithStage4c(): void
|
||
{
|
||
$error = self::assertToolError(
|
||
$this->ability(self::untouched())->execute(['variable' => 'Eckenradius']),
|
||
'unsupported'
|
||
);
|
||
|
||
self::assertStringContainsString('Variablen folgen mit Etappe 4c.', $error['hint']);
|
||
}
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
public function testAVariableNeedsTheVariableRightsToo(): void
|
||
{
|
||
$ability = $this->ability(self::untouched(), null, null, null, null, true, self::variableAdapter(false));
|
||
|
||
self::assertToolError($ability->permission(['variable' => 'Eckenradius']), 'forbidden');
|
||
self::assertToolError($ability->execute(['variable' => 'Eckenradius']), 'forbidden');
|
||
self::assertTrue($ability->permission(['preset' => self::LESETEXT]));
|
||
}
|
||
|
||
public function testUnknownAmbiguousAndColorVariables(): void
|
||
{
|
||
$ability = $this->ability(self::untouched());
|
||
|
||
$unknown = self::assertToolError($ability->execute(['variable' => 'Fehlt']), 'not_found');
|
||
self::assertStringContainsString('lit/divi-colors-set', $unknown['hint']);
|
||
$color = self::assertToolError($ability->execute(['variable' => 'gcid-primary-color']), 'unsupported');
|
||
self::assertStringContainsString('nur deaktivieren', $color['hint']);
|
||
self::assertArrayHasKey('variable', self::errorFields($ability->execute(['variable' => ' '])));
|
||
$both = $ability->execute(['variable' => 'Eckenradius', 'preset' => self::LESETEXT]);
|
||
self::assertSame('entweder preset oder variable angeben, nicht beides', self::errorFields($both)['variable']);
|
||
}
|
||
|
||
public function testVariableDryRunListsPagesAndThePresetsThatUseIt(): void
|
||
{
|
||
$pages = Mockery::mock(PageRewriter::class);
|
||
$pages->expects('preview')
|
||
->with(
|
||
Mockery::on(self::referencing(self::RADIUS)),
|
||
Mockery::on(self::resolving(self::RADIUS, '8px')),
|
||
null
|
||
)
|
||
->andReturn(PageRewriteReportTest::preview(1));
|
||
$pages->expects('apply')->never();
|
||
$ability = $this->ability($pages, self::readOnlyWriter(self::storedWithVariable()));
|
||
|
||
$dry = self::assertToolError($ability->execute(['variable' => 'eckenradius']), 'confirm_required');
|
||
|
||
self::assertSame(12, $dry['pages'][0]['post_id']);
|
||
self::assertSame([['aaaaaaaaaa', 'changed']], array_map(
|
||
static fn (array $row): array => [$row['id'], $row['action']],
|
||
$dry['diff']
|
||
));
|
||
self::assertSame(PresetBook::fromDivi(self::storedWithVariable())->rev(), $dry['design_rev']);
|
||
self::assertStringStartsWith('Entkoppeln von Variable „Eckenradius“ – betroffen', $dry['message']);
|
||
self::assertStringContainsString('die Verweise in 1 Presets (diff, mit Snapshot)', $dry['message']);
|
||
}
|
||
|
||
public function testAStringsVariableKeepsBeforeAndAfterAndArchivedOnesWork(): void
|
||
{
|
||
$pages = Mockery::mock(PageRewriter::class);
|
||
$pages->expects('preview')
|
||
->with(
|
||
Mockery::on(self::referencing(self::CLAIM)),
|
||
Mockery::on(self::resolving(self::CLAIM, '„Gut beraten“', ['before' => '„', 'after' => '“'])),
|
||
null
|
||
)
|
||
->andReturn(PageRewriteReportTest::preview(1));
|
||
|
||
$dry = self::assertToolError($this->ability($pages)->execute(['variable' => self::CLAIM]), 'confirm_required');
|
||
|
||
self::assertArrayNotHasKey('diff', $dry, 'no preset uses the claim');
|
||
}
|
||
|
||
public function testVariableConfirmResolvesThePresetsFirstThenThePages(): void
|
||
{
|
||
$calls = [];
|
||
$stored = self::storedWithVariable();
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('presets')->andReturn($stored);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$writer->expects('savePresets')
|
||
->with(
|
||
Mockery::on(static fn (array $rest): bool => ($rest['module'][0]['items'][0]['attrs']['button']
|
||
['decoration']['border']['desktop']['value']['radius']['topLeft'] ?? null) === '8px'),
|
||
false
|
||
)
|
||
->andReturnUsing(static function () use (&$calls, $stored): array {
|
||
$calls[] = 'presets';
|
||
|
||
return PresetBook::fromDivi($stored)->withVariableResolved(self::RADIUS, '8px')->data();
|
||
});
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->expects('save')->with('presets', $stored, 'lit/divi-design-detach')->andReturn('presets-5');
|
||
$snapshots->expects('attachLog');
|
||
$pages = Mockery::mock(PageRewriter::class);
|
||
$pages->expects('apply')
|
||
->with(
|
||
'lit/divi-design-detach',
|
||
Mockery::on(self::referencing(self::RADIUS)),
|
||
Mockery::on(self::resolving(self::RADIUS, '8px')),
|
||
null,
|
||
'Entkoppeln von Variable „Eckenradius“'
|
||
)
|
||
->andReturnUsing(static function () use (&$calls): array {
|
||
$calls[] = 'pages';
|
||
|
||
return PageRewriteReportTest::applied(1, 0);
|
||
});
|
||
$ability = $this->ability($pages, $writer, $snapshots, new FakeChangeLog(9));
|
||
|
||
$result = $ability->execute([
|
||
'variable' => self::RADIUS,
|
||
'confirm' => true,
|
||
'design_rev' => PresetBook::fromDivi($stored)->rev(),
|
||
]);
|
||
|
||
self::assertSame(['presets', 'pages'], $calls);
|
||
self::assertSame('presets-5', $result['snapshot_id']);
|
||
self::assertSame('aaaaaaaaaa', $result['diff'][0]['id']);
|
||
self::assertStringStartsWith('Presets: 1 geändert.', $result['summary']);
|
||
self::assertStringEndsWith(
|
||
'Jetzt kann lit/divi-variables-set die Variable archivieren (status: archived).',
|
||
$result['summary']
|
||
);
|
||
}
|
||
|
||
public function testVariablePostIdsSkipThePresetStep(): void
|
||
{
|
||
$pages = Mockery::mock(PageRewriter::class);
|
||
$pages->expects('apply')
|
||
->with('lit/divi-design-detach', Mockery::any(), Mockery::any(), [12], Mockery::type('string'))
|
||
->andReturn(PageRewriteReportTest::applied(1, 0));
|
||
$ability = $this->ability($pages, self::readOnlyWriter(self::storedWithVariable()));
|
||
|
||
$result = $ability->execute(['variable' => 'Eckenradius', 'post_ids' => [12], 'confirm' => true]);
|
||
|
||
self::assertSame([], $result['diff']);
|
||
self::assertNull($result['snapshot_id']);
|
||
self::assertStringNotContainsString('lit/divi-variables-set', $result['summary']);
|
||
}
|
||
|
||
public function testNoVariableReferenceLeftPointsToArchiving(): void
|
||
{
|
||
$pages = Mockery::mock(PageRewriter::class);
|
||
$pages->expects('preview')->andReturn(PageRewriteReportTest::preview(0));
|
||
|
||
$result = $this->ability($pages)->execute(['variable' => 'Eckenradius']);
|
||
|
||
self::assertSame(
|
||
'Keine Verweise auf Variable „Eckenradius“ gefunden – nichts zu tun. Jetzt kann lit/divi-variables-set '
|
||
. 'die Variable archivieren (status: archived).',
|
||
$result['summary']
|
||
);
|
||
}
|
||
|
||
public function testFeaturesPerBranch(): void
|
||
{
|
||
$ability = $this->ability(self::untouched());
|
||
|
||
self::assertNotContains(DiviFeature::VariableWrite, $ability->diviFeatures(['preset' => self::LESETEXT]));
|
||
self::assertContains(DiviFeature::VariableWrite, $ability->diviFeatures(['variable' => 'Eckenradius']));
|
||
self::assertContains(DiviFeature::PresetWrite, $ability->diviFeatures(['variable' => 'Eckenradius']));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 16: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviDesignDetachAbilityTest`
|
||
Expected: FAIL – alle 33 Tests mit `TypeError: LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility::__construct(): Argument #2 ($change) must be of type LIT\MCP\Integrations\Divi\Design\DesignChange, Mockery_…_GlobalVariablesAdapter given`.
|
||
|
||
- [ ] **Step 17: Ability um den Zweig `variable` erweitern**
|
||
|
||
`src/Integrations/Divi/Design/DiviDesignDetachAbility.php` (Zeilenangaben: Stand nach Step 6):
|
||
|
||
*Klassen-Docblock (Zeilen 23–32)* – vorher:
|
||
|
||
```php
|
||
/**
|
||
* lit/divi-design-detach (spec §6.8, ruling R10 – presets only): removes every explicit reference to one preset –
|
||
* from the modulePreset/groupPreset stacks of pages, library entries and Theme Builder layouts (DetachRewrite through
|
||
* the PageRewriter) and, for a group preset without post_ids, from the groupPresets of other presets (DesignChange,
|
||
* snapshot "presets"). Modules fall back to the next preset of their stack or the default. The dry run is ONE
|
||
* confirm_required with the pages and, when other presets nest it, the diff and design_rev of the preset step.
|
||
* Confirmed, the preset step runs first – its plan carries the page usages, so a blocked live post refuses before
|
||
* anything is written (P8a) – then at most PageRewriter::PAGE_LIMIT pages. A default preset keeps working implicitly
|
||
* (Divi applies it); only explicit references go. Variables follow with Etappe 4c.
|
||
*/
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
/**
|
||
* lit/divi-design-detach (spec §6.8, rulings R10, V9): removes every explicit reference to one preset or one global
|
||
* variable from pages, library entries and Theme Builder layouts (DetachRewrite or VariableDetachRewrite through the
|
||
* PageRewriter) and, without post_ids, from presets that nest it (DesignChange, snapshot "presets"): a group preset
|
||
* leaves the groupPresets of other presets, a variable reference in a preset becomes the variable's value. Modules
|
||
* fall back to the next preset of their stack or the default; a variable reference becomes the value (the variable
|
||
* itself stays as it is). The dry run is ONE confirm_required with the pages and, when presets nest the target, the
|
||
* diff and design_rev of the preset step. Confirmed, the preset step runs first – its plan carries the page usages,
|
||
* so a blocked live post refuses before anything is written (P8a) – then at most PageRewriter::PAGE_LIMIT pages. A
|
||
* default preset keeps working implicitly (Divi applies it); only explicit references go. Global colors have no
|
||
* detach (ruling V9: they are only deactivated).
|
||
*/
|
||
```
|
||
|
||
*Konstante `VARIABLES` entfällt (Zeilen 35–37)* – vorher:
|
||
|
||
```php
|
||
private const VARIABLES = 'Variablen folgen mit Etappe 4c.';
|
||
|
||
private const DELETE_HINT
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
private const DELETE_HINT
|
||
```
|
||
|
||
*neue Konstanten nach `DEFAULT_NOTE` (Zeilen 39–40)* – vorher:
|
||
|
||
```php
|
||
private const DEFAULT_NOTE = '„%s“ ist das Standard-Preset von %s: Divi wendet es weiter auf Module ohne eigenes '
|
||
. 'Preset an; entfernt werden nur die ausdrücklichen Verweise.';
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
private const DEFAULT_NOTE = '„%s“ ist das Standard-Preset von %s: Divi wendet es weiter auf Module ohne eigenes '
|
||
. 'Preset an; entfernt werden nur die ausdrücklichen Verweise.';
|
||
|
||
private const ARCHIVE_HINT = 'Jetzt kann lit/divi-variables-set die Variable archivieren (status: archived).';
|
||
|
||
private const NO_COLORS = 'Globale Farben lassen sich nicht entkoppeln, nur deaktivieren (lit/divi-colors-set).';
|
||
```
|
||
|
||
*Konstruktor (Zeilen 43–45)* – vorher:
|
||
|
||
```php
|
||
private DesignSystemWriter $writer,
|
||
private DesignChange $change,
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
private DesignSystemWriter $writer,
|
||
private GlobalVariablesAdapter $variables,
|
||
private DesignChange $change,
|
||
```
|
||
|
||
*`label()` (Zeile 60)* – vorher:
|
||
|
||
```php
|
||
return 'Divi-Preset-Verweise entfernen';
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
return 'Divi-Preset- und Variablen-Verweise entfernen';
|
||
```
|
||
|
||
*`description()` (Zeilen 65–76)* – vorher:
|
||
|
||
```php
|
||
return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) aus '
|
||
. 'Seiten, Bibliothekseinträgen und Theme-Builder-Layouts und – bei Gruppen-Presets – aus anderen Presets; '
|
||
. 'Module fallen auf das nächste Preset im Stapel bzw. das Standard-Preset zurück. post_ids beschränkt auf '
|
||
. 'diese Seiten (Verweise in Presets bleiben dann). Zuerst Probelauf (pages; bei Verweisen in Presets diff '
|
||
. 'und design_rev), nach Rückfrage beim Nutzer confirm: true (mit design_rev, wenn der Probelauf eine '
|
||
. 'nennt); je Aufruf höchstens ' . PageRewriter::PAGE_LIMIT . ' Seiten, remaining nennt den Rest. Mit '
|
||
. 'post_ids können nur die genannten Seiten das Schreiben blockieren und die Obergrenze für sehr viele '
|
||
. 'Seiten gilt nicht; eine genannte veröffentlichte, private oder geplante Seite ohne Bearbeitungsrecht '
|
||
. 'verweigert den ganzen Aufruf (forbidden, blocked_ids), eine genannte andere Seite ohne '
|
||
. 'Bearbeitungsrecht steht in skipped (bei mehr als 2000 Live-Fundstellen entfällt die Vorabprüfung, '
|
||
. 'dann auch eine genannte Live-Seite). Danach kann lit/divi-presets-delete das Preset löschen. '
|
||
. self::VARIABLES;
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) oder auf '
|
||
. 'eine globale Variable (variable: Name oder ID) aus Seiten, Bibliothekseinträgen und '
|
||
. 'Theme-Builder-Layouts und – bei Gruppen-Presets und Variablen – aus Presets; Module fallen auf das '
|
||
. 'nächste Preset im Stapel bzw. das Standard-Preset zurück, ein Variablenverweis wird durch den aktuellen '
|
||
. 'Wert der Variablen ersetzt (die Variable selbst bleibt; Verweise mitten in einem längeren Text bleiben '
|
||
. 'stehen und stehen als Hinweis in warnings). post_ids beschränkt auf diese Seiten (Verweise in Presets '
|
||
. 'bleiben dann). Zuerst Probelauf (pages; bei Verweisen in Presets diff und design_rev), nach Rückfrage '
|
||
. 'beim Nutzer confirm: true (mit design_rev, wenn der Probelauf eine nennt); je Aufruf höchstens '
|
||
. PageRewriter::PAGE_LIMIT . ' Seiten, remaining nennt den Rest. Mit post_ids können nur die genannten '
|
||
. 'Seiten das Schreiben blockieren und die Obergrenze für sehr viele Seiten gilt nicht; eine genannte '
|
||
. 'veröffentlichte, private oder geplante Seite ohne Bearbeitungsrecht verweigert den ganzen Aufruf '
|
||
. '(forbidden, blocked_ids), eine genannte andere Seite ohne Bearbeitungsrecht steht in skipped (bei mehr '
|
||
. 'als 2000 Live-Fundstellen entfällt die Vorabprüfung, dann auch eine genannte Live-Seite). Danach kann '
|
||
. 'lit/divi-presets-delete das Preset löschen bzw. lit/divi-variables-set die Variable archivieren. '
|
||
. self::NO_COLORS;
|
||
```
|
||
|
||
*`inputSchema()`, Eigenschaft `variable` (Zeile 93)* – vorher:
|
||
|
||
```php
|
||
'variable' => ['type' => 'string', 'description' => self::VARIABLES],
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
'variable' => [
|
||
'type' => 'string',
|
||
'description' => 'Name oder ID einer globalen Variablen (statt preset). ' . self::NO_COLORS,
|
||
],
|
||
```
|
||
|
||
*`diviFeatures()` – Docblock und Anfang (Zeilen 118–124)* – vorher:
|
||
|
||
```php
|
||
/**
|
||
* Like lit/divi-presets-assign (it rewrites every kind of post), plus the preset step that removes references
|
||
* nested in other presets (PresetWrite).
|
||
*/
|
||
public function diviFeatures(array $input): array
|
||
{
|
||
return [
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
/**
|
||
* Like lit/divi-presets-assign (it rewrites every kind of post), plus the preset step that removes references
|
||
* nested in presets (PresetWrite); a variable is read and its rights checked through GlobalVariablesAdapter
|
||
* (VariableWrite).
|
||
*/
|
||
public function diviFeatures(array $input): array
|
||
{
|
||
$features = [
|
||
```
|
||
|
||
*Ende von `diviFeatures()`, `permission()` und Anfang von `execute()` (Zeilen 133–154)* – vorher:
|
||
|
||
```php
|
||
DiviFeature::Roles,
|
||
];
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return DesignRights::presets($this->writer) ?? true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = DesignRights::presets($this->writer);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
if (array_key_exists('variable', $input)) {
|
||
return Errors::unsupported(
|
||
'Verweise auf Variablen entfernt LIT MCP noch nicht; nichts wurde geändert.',
|
||
self::VARIABLES . ' Bis dahin nur Presets angeben (preset).'
|
||
);
|
||
}
|
||
$target = $this->presetTarget($input);
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
DiviFeature::Roles,
|
||
];
|
||
|
||
return array_key_exists('variable', $input) ? [...$features, DiviFeature::VariableWrite] : $features;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return $this->rights($input) ?? true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = $this->rights($input);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
if (array_key_exists('variable', $input) && array_key_exists('preset', $input)) {
|
||
return self::invalid(['variable' => 'entweder preset oder variable angeben, nicht beides']);
|
||
}
|
||
$target = array_key_exists('variable', $input) ? $this->variableTarget($input) : $this->presetTarget($input);
|
||
```
|
||
|
||
*neue Methoden `rights()` und `variableTarget()` vor `presetTarget()` (Docblock-Anfang Zeilen 162–163)* – vorher:
|
||
|
||
```php
|
||
/**
|
||
* The preset to detach, resolved on the live inventory.
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
/**
|
||
* Spec §10: presets need the preset rights; a variable also the variable rights – the preset step writes presets
|
||
* either way (V9).
|
||
*
|
||
* @param array<string, mixed> $input
|
||
*/
|
||
private function rights(array $input): ?WP_Error
|
||
{
|
||
$refusal = DesignRights::presets($this->writer);
|
||
if ($refusal !== null || ! array_key_exists('variable', $input)) {
|
||
return $refusal;
|
||
}
|
||
|
||
return DesignRights::variables($this->writer, $this->variables);
|
||
}
|
||
|
||
/**
|
||
* The global variable to detach, resolved on the live inventory; its current value replaces the references
|
||
* (V9: whether it is active or archived does not matter).
|
||
*
|
||
* @param array<string, mixed> $input
|
||
*/
|
||
private function variableTarget(array $input): DetachTarget|WP_Error
|
||
{
|
||
$needle = is_string($input['variable'] ?? null) ? trim($input['variable']) : '';
|
||
if ($needle === '') {
|
||
return self::invalid(['variable' => 'Name oder ID der Variablen erwartet']);
|
||
}
|
||
try {
|
||
$postIds = PostIdsInput::parse($input['post_ids'] ?? null);
|
||
} catch (InvalidArgumentException $e) {
|
||
return self::invalid(['post_ids' => $e->getMessage()]);
|
||
}
|
||
if (str_starts_with($needle, 'gcid-')) {
|
||
return Errors::unsupported(
|
||
sprintf('„%s“ ist eine globale Farbe; nichts wurde geändert.', $needle),
|
||
self::NO_COLORS
|
||
);
|
||
}
|
||
|
||
$book = VariableBook::fromDivi($this->variables->read());
|
||
try {
|
||
$found = $book->find($needle);
|
||
} catch (InvalidArgumentException $e) {
|
||
return self::invalid(['variable' => $e->getMessage()]);
|
||
}
|
||
if ($found === null) {
|
||
return Errors::notFound(
|
||
sprintf('Unbekannte Variable „%s“; nichts wurde geändert.', $needle),
|
||
'Vorhandene Variablen mit Namen und IDs liefert lit/divi-design-system (direkt: '
|
||
. 'lit-divi-design-system). ' . self::NO_COLORS
|
||
);
|
||
}
|
||
|
||
$id = $found['id'];
|
||
$type = $found['type'];
|
||
$entry = $book->entry($type, $id) ?? [];
|
||
$label = is_string($entry['label'] ?? null) && $entry['label'] !== '' ? $entry['label'] : $id;
|
||
$value = $entry['value'] ?? null;
|
||
if (! is_string($value)) {
|
||
return Errors::unsupported(
|
||
sprintf('Variable „%s“ (Typ %s) hat keinen Textwert; nichts wurde geändert.', $label, $type),
|
||
'Verweise auf diese Variable im Divi-Builder lösen.'
|
||
);
|
||
}
|
||
$nested = $postIds === null && PresetBook::fromDivi($this->writer->presets())->referencing($id) !== [];
|
||
|
||
return new DetachTarget(
|
||
sprintf('Variable „%s“', $label),
|
||
$postIds,
|
||
new ReferenceUsageQuery([$id]),
|
||
static fn (): NodeRewrite => new VariableDetachRewrite($id, $value, $type),
|
||
$nested ? static fn (PresetBook $presets): PresetBook => $presets->withVariableResolved($id, $value, $type === 'strings') : null,
|
||
'Presets',
|
||
[],
|
||
self::ARCHIVE_HINT
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The preset to detach, resolved on the live inventory.
|
||
```
|
||
|
||
- [ ] **Step 18: Konstruktoraufruf in `DiviIntegration`, Fähigkeiten-Tabelle, Smoke**
|
||
|
||
Ohne die Anpassung bricht `DiviIntegration::abilities()` (u. a. `DiviIntegrationTest`, `DiviToolFeaturesTest`). Nur der Konstruktoraufruf; alles Weitere der Verdrahtung bleibt Task 10.
|
||
|
||
`src/Integrations/Divi/DiviIntegration.php`:
|
||
|
||
*Import (nach `DiviDesignDetachAbility`)* – vorher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility;
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
|
||
```
|
||
|
||
*Konstruktoraufruf (Zeilen 275–281)* – vorher:
|
||
|
||
```php
|
||
new DiviDesignDetachAbility(
|
||
$this->designWriter,
|
||
$this->designChange(),
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
new DiviDesignDetachAbility(
|
||
$this->designWriter,
|
||
new GlobalVariablesAdapter(),
|
||
$this->designChange(),
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php` (die Zeile in `EXPECTED` bleibt: ohne `variable` ändert sich nichts):
|
||
|
||
*`inputCases()`, vor `'library-list'` (Zeile 176)* – vorher:
|
||
|
||
```php
|
||
'library-list' => ['lit/library-list', [], ['library-read', 'roles']],
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
'design-detach of a variable' => [
|
||
'lit/divi-design-detach', ['variable' => 'Eckenradius'],
|
||
[
|
||
'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read',
|
||
'library-write', 'tb-write', 'roles', 'variable-write',
|
||
],
|
||
],
|
||
'library-list' => ['lit/library-list', [], ['library-read', 'roles']],
|
||
```
|
||
|
||
`tests/Integration/divi-presets-assign-smoke.sh` (eine unbekannte Variable ist jetzt `not_found`):
|
||
|
||
*Zeilen 219–220* – vorher:
|
||
|
||
```bash
|
||
UNSUPPORTED=$(ability lit/divi-design-detach '{"variable": "Irgendeine Variable"}')
|
||
[ "$(error_code "$UNSUPPORTED")" = "unsupported" ] || fail "detach of a variable: $UNSUPPORTED"
|
||
```
|
||
|
||
nachher:
|
||
|
||
```bash
|
||
UNKNOWN_VARIABLE=$(ability lit/divi-design-detach '{"variable": "Irgendeine Variable"}')
|
||
[ "$(error_code "$UNKNOWN_VARIABLE")" = "not_found" ] || fail "detach of an unknown variable: $UNKNOWN_VARIABLE"
|
||
```
|
||
|
||
*Zeile 230* – vorher:
|
||
|
||
```bash
|
||
pass "group preset is in use, variable detach unsupported, stale design_rev refused without writing"
|
||
```
|
||
|
||
nachher:
|
||
|
||
```bash
|
||
pass "group preset is in use, unknown variable not found, stale design_rev refused without writing"
|
||
```
|
||
|
||
- [ ] **Step 19: GREEN, Gesamtlauf, Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignDetachAbilityTest|DiviToolFeaturesTest|DiviIntegrationTest/'`
|
||
Expected: PASS (`DiviDesignDetachAbilityTest` 33 Tests, `DiviToolFeaturesTest` 35).
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests grün, phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 20: Commit**
|
||
|
||
Commit wie in den Global Constraints:
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DiviDesignDetachAbility.php \
|
||
tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php \
|
||
src/Integrations/Divi/DiviIntegration.php \
|
||
tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php \
|
||
tests/Integration/divi-presets-assign-smoke.sh
|
||
git commit -m "feat: lit/divi-design-detach entkoppelt Variablen – Verweise in Seiten und Presets werden durch den Wert ersetzt (V9)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
Integration (optional, Smoke-Anpassung): `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-assign-smoke.sh` → alle `pass`. Der Variablen-Detach gegen echtes Divi folgt im Smoke von Task 11.
|
||
|
||
---
|
||
|
||
### Task 9: `RestorePlanner` je Bereich – Restore für Farben und Variablen (V1b, V10)
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/RestorePlanner.php`, `src/Integrations/Divi/Design/PresetRestorePlanner.php`, `src/Integrations/Divi/Design/ColorRestorePlanner.php`, `src/Integrations/Divi/Design/VariableRestorePlanner.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php`, `tests/Unit/Integrations/Divi/Design/ColorRestorePlannerTest.php`, `tests/Unit/Integrations/Divi/Design/VariableRestorePlannerTest.php`
|
||
- Modify: `src/Integrations/Divi/Design/DiviDesignRestoreAbility.php`, `src/Integrations/Divi/DiviIntegration.php` (nur der Konstruktoraufruf des Restore-Tools)
|
||
- Modify (Tests): `tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php`, `tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes (Schnittstellenvertrag, Tasks 1–7): `DiviFeature::ColorWrite`, `::VariableWrite`; `DesignRights::colors()`, `::variables()`; `GlobalColorsAdapter`, `GlobalVariablesAdapter` (mockbar, ohne Konstruktorargumente); `ColorBook::fromDivi()/data()/entry()/label()`, `ColorDiff::between()/summary()`; `VariableBook::fromDivi()/data()/restoredFrom()/onlyHere()`, `VariableDiff::between()/summary()`; `new ColorArea(GlobalColorsAdapter)`, `new VariableArea(GlobalVariablesAdapter)`. Vorhanden: `DesignChange::run()`, `SnapshotStore::get()` (Eintrag mit `data`), `PresetArea`, `PresetBook`, `PresetDiff`, `DesignRights::presets()/markup()/unfilteredHtml()`, `FeatureSets::merge()`.
|
||
- Produces:
|
||
- `interface RestorePlanner { public function area(): string; public function designArea(): DesignArea; public function rights(): ?WP_Error; public function features(): array; public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error; }` – `$snapshot` ist das Feld `data` des Snapshots.
|
||
- `PresetRestorePlanner::__construct(DesignSystemWriter $writer, ?Closure $unfiltered = null)`
|
||
- `ColorRestorePlanner::__construct(DesignSystemWriter $writer, GlobalColorsAdapter $colors)`
|
||
- `VariableRestorePlanner::__construct(DesignSystemWriter $writer, GlobalVariablesAdapter $variables)`
|
||
- **Neue Konstruktor-Signatur** `DiviDesignRestoreAbility::__construct(DesignChange $change, SnapshotStore $snapshots, RestorePlanner ...$planners)`; `diviFeatures($input)` = Fähigkeiten des Planers zum Bereich der `snapshot_id`, ohne gültige ID bzw. ohne Planer die Vereinigung aller Planer (fail closed; das liest auch `FeatureOverview` mit `[]`).
|
||
|
||
Regeln (V10, V1b, V4, Spec §5/§10):
|
||
- Bereich = erste Gruppe der Snapshot-ID. Rechte und Fähigkeiten sind die des Bereichs; ohne gültige ID prüft die Ability die Rechte aller Planer (fail closed). `unsupported` („Theme-Optionen folgen mit Etappe 4d.“) nur noch für `theme_options`.
|
||
- Presets: Code und Texte unverändert verschoben (Ersetzen des ganzen Bestands, `mayRemove`, Markup-Sperre).
|
||
- Farben: der Snapshot ersetzt alle gespeicherten Farben (`mayRemove: true` → `GlobalColorsAdapter::save(…, true)`). Farben, die es nur jetzt gibt, entfallen: der Planer hängt dafür Zeilen `action: removed` an den Diff (ColorDiff kennt keine Entfernung), nennt sie in `summary` („Entfernt: N.“) und in einer Warnung.
|
||
- Variablen: `VariableBook::restoredFrom()` – jede Variable des Snapshots kommt zurück, neuere bleiben **archiviert** erhalten (Divi lehnt fehlende `gvid-`-IDs ab), je eine Warnung aus `onlyHere()`; `mayRemove: false`.
|
||
|
||
#### 9a – `RestorePlanner` und `PresetRestorePlanner` (Refactor, Verhalten unverändert)
|
||
|
||
- [ ] **Step 1: Absicherung: bestehende Restore-Tests grün**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignRestoreAbilityTest|DiviToolFeaturesTest/'`
|
||
Expected: `OK (39 tests, …)` (4 + 35 nach Task 8).
|
||
|
||
- [ ] **Step 2: Failing Test `PresetRestorePlannerTest` schreiben**
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetArea;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class PresetRestorePlannerTest extends TestCase
|
||
{
|
||
private static function planner(bool $unfiltered = true): PresetRestorePlanner
|
||
{
|
||
return new PresetRestorePlanner(Mockery::mock(DesignSystemWriter::class), static fn (): bool => $unfiltered);
|
||
}
|
||
|
||
public function testAreaFeaturesAndDesignArea(): void
|
||
{
|
||
$planner = self::planner();
|
||
|
||
self::assertSame('presets', $planner->area());
|
||
self::assertSame([DiviFeature::DesignRead, DiviFeature::PresetWrite], $planner->features());
|
||
self::assertInstanceOf(PresetArea::class, $planner->designArea());
|
||
}
|
||
|
||
public function testPlansTheWholeSnapshotWithRemovals(): void
|
||
{
|
||
$older = PresetBookTest::stored();
|
||
unset($older['module']['divi/button']['items']['bbbbbbbbbb']);
|
||
|
||
$plan = self::planner()->plan(PresetBookTest::stored(), $older, 'presets-20261016T100000Z-aaaaaa');
|
||
|
||
self::assertInstanceOf(DesignPlan::class, $plan);
|
||
self::assertTrue($plan->mayRemove);
|
||
self::assertSame([['bbbbbbbbbb', 'removed']], array_map(
|
||
static fn (array $row): array => [$row['id'], $row['action']],
|
||
$plan->diff
|
||
));
|
||
self::assertStringStartsWith('Snapshot presets-20261016T100000Z-aaaaaa wiederherstellen – ', $plan->summary);
|
||
self::assertStringContainsString('Standard-Preset', $plan->warnings[0]);
|
||
}
|
||
|
||
public function testMarkupNeedsUnfilteredHtml(): void
|
||
{
|
||
$plan = self::planner(false)->plan(PresetBookTest::stored(), PresetBookTest::storedWithMarkup(), 'presets-1');
|
||
|
||
self::assertToolError($plan, 'forbidden');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter PresetRestorePlannerTest`
|
||
Expected: FAIL – `Error: Class "LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner" not found`.
|
||
|
||
- [ ] **Step 4: `RestorePlanner` und `PresetRestorePlanner` anlegen**
|
||
|
||
Der Planungscode ist die bisherige Closure aus `DiviDesignRestoreAbility::execute()` (Zeilen 125–147), die Konstante `MARKUP` (Zeilen 26–27) und `DesignRights::presets()` – unverändert verschoben.
|
||
|
||
Datei `src/Integrations/Divi/Design/RestorePlanner.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* How lit/divi-design-restore writes a snapshot of one area back (spec §5, ruling V10): the area for DesignChange, the
|
||
* rights and Divi features of that area, and the plan from the current inventory to the restored one.
|
||
*/
|
||
interface RestorePlanner
|
||
{
|
||
/** The snapshot area this planner restores – one of SnapshotStore::AREAS. */
|
||
public function area(): string;
|
||
|
||
public function designArea(): DesignArea;
|
||
|
||
/** Why the current user may not restore this area (spec §10); null when allowed. */
|
||
public function rights(): ?WP_Error;
|
||
|
||
/** @return list<DiviFeature> */
|
||
public function features(): array;
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $current The area's inventory now (DesignArea::raw(), read by DesignChange).
|
||
* @param array<array-key, mixed> $snapshot The data of the snapshot $snapshotId.
|
||
*/
|
||
public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error;
|
||
}
|
||
```
|
||
|
||
Datei `src/Integrations/Divi/Design/PresetRestorePlanner.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Restores a snapshot of the presets (Etappe 4a): the snapshot replaces the whole inventory (presets missing in it are
|
||
* removed); without unfiltered_html it is refused while the snapshot or the current inventory holds HTML/CSS that
|
||
* Divi would strip (final review I-2).
|
||
*/
|
||
final class PresetRestorePlanner implements RestorePlanner
|
||
{
|
||
private const MARKUP = 'Der Snapshot oder der aktuelle Bestand enthält Presets mit HTML/CSS, das beim Speichern '
|
||
. 'ohne das Recht unfiltered_html verloren ginge; nichts wurde gespeichert.';
|
||
|
||
/** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */
|
||
public function __construct(private DesignSystemWriter $writer, private ?Closure $unfiltered = null)
|
||
{
|
||
$this->unfiltered ??= DesignRights::unfilteredHtml();
|
||
}
|
||
|
||
public function area(): string
|
||
{
|
||
return 'presets';
|
||
}
|
||
|
||
public function designArea(): DesignArea
|
||
{
|
||
return new PresetArea($this->writer);
|
||
}
|
||
|
||
public function rights(): ?WP_Error
|
||
{
|
||
return DesignRights::presets($this->writer);
|
||
}
|
||
|
||
/** Writes the presets (PresetWrite), reading them first (DesignRead). */
|
||
public function features(): array
|
||
{
|
||
return [DiviFeature::DesignRead, DiviFeature::PresetWrite];
|
||
}
|
||
|
||
public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error
|
||
{
|
||
$before = PresetBook::fromDivi($current);
|
||
$after = PresetBook::fromDivi($snapshot);
|
||
$refusal = DesignRights::markup(
|
||
$this->unfiltered,
|
||
array_merge($before->markupOutside([]), $after->markupOutside([])),
|
||
self::MARKUP
|
||
);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
$diff = PresetDiff::between($before, $after);
|
||
|
||
return new DesignPlan(
|
||
$after->data(),
|
||
$diff,
|
||
sprintf('Snapshot %s wiederherstellen – %s', $snapshotId, PresetDiff::summary($diff)),
|
||
null,
|
||
['Seiten, die Presets verwenden, die es im Snapshot nicht gibt, behalten den Verweis; das Modul fällt '
|
||
. 'dann auf das Standard-Preset zurück.'],
|
||
true
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: GREEN**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter PresetRestorePlannerTest`
|
||
Expected: `OK (3 tests, 11 assertions)`.
|
||
|
||
- [ ] **Step 6: `DiviDesignRestoreAbility` auf Planer umbauen, Aufrufer anpassen**
|
||
|
||
Ganze Datei `src/Integrations/Divi/Design/DiviDesignRestoreAbility.php` nach dem Umbau (in 9a nur mit dem Presets-Planer verdrahtet; der Hinweis für andere Bereiche bleibt bis 9c wörtlich):
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\Release\FeatureSets;
|
||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/divi-design-restore (spec §5, G6, ruling V10): writes a snapshot back through the change pipeline – dry run with
|
||
* the diff to the current state, confirm with design_rev, and first a snapshot of the current state, so a restore can
|
||
* be undone as well. The area comes from the snapshot ID; its RestorePlanner brings the rights, the Divi features and
|
||
* the plan. Areas without a planner are not restorable yet.
|
||
*/
|
||
final class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures
|
||
{
|
||
private const ID = '/^(colors|variables|presets|theme_options)-\d{8}T\d{6}Z-[0-9a-f]{6}$/D';
|
||
|
||
/** @var array<string, RestorePlanner> area => planner */
|
||
private array $planners = [];
|
||
|
||
public function __construct(
|
||
private DesignChange $change,
|
||
private SnapshotStore $snapshots,
|
||
RestorePlanner ...$planners
|
||
) {
|
||
foreach ($planners as $planner) {
|
||
$this->planners[$planner->area()] = $planner;
|
||
}
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/divi-design-restore';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Designsystem wiederherstellen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Stellt einen Snapshot des Designsystems wieder her (snapshot_id aus lit/divi-design-snapshots oder '
|
||
. 'einer Änderungsantwort). Zuerst Probelauf (Unterschied zum aktuellen Stand, design_rev), nach Rückfrage '
|
||
. 'beim Nutzer confirm: true; vorher wird der aktuelle Stand selbst gesichert.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => ['snapshot_id' => ['type' => 'string']] + DesignChange::inputProperties(),
|
||
'required' => ['snapshot_id'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return DesignChange::outputSchema();
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::destructive(false);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
/** The features of the snapshot's area; without a restorable area those of every area (fail closed). */
|
||
public function diviFeatures(array $input): array
|
||
{
|
||
return FeatureSets::merge(...array_map(
|
||
static fn (RestorePlanner $planner): array => $planner->features(),
|
||
$this->plannersFor($input)
|
||
));
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return $this->rights($input) ?? true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = $this->rights($input);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
$id = is_string($input['snapshot_id'] ?? null) ? $input['snapshot_id'] : '';
|
||
$area = self::areaOf($input);
|
||
if ($area === null) {
|
||
return Errors::invalidInput(
|
||
'Ungültige Snapshot-ID.',
|
||
['snapshot_id' => 'z. B. presets-20261016T101500Z-a1b2c3']
|
||
);
|
||
}
|
||
$planner = $this->planners[$area] ?? null;
|
||
if ($planner === null) {
|
||
return Errors::unsupported(
|
||
'Snapshots dieses Bereichs lassen sich noch nicht wiederherstellen.',
|
||
'Farben und Variablen folgen mit Etappe 4c, Theme-Optionen mit 4d.'
|
||
);
|
||
}
|
||
$snapshot = $this->snapshots->get($area, $id);
|
||
if ($snapshot === null) {
|
||
return Errors::notFound(
|
||
sprintf('Snapshot %s gibt es nicht (mehr).', $id),
|
||
'Vorhandene Snapshots mit lit/divi-design-snapshots auflisten; je Bereich bleiben die letzten 20.'
|
||
);
|
||
}
|
||
|
||
return $this->change->run(
|
||
$this->name(),
|
||
$planner->designArea(),
|
||
$input,
|
||
static fn (array $raw): DesignPlan|WP_Error => $planner->plan($raw, $snapshot['data'], $id)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Spec §10: the rights of the snapshot's area; without a restorable area those of every area (fail closed).
|
||
*
|
||
* @param array<string, mixed> $input
|
||
*/
|
||
private function rights(array $input): ?WP_Error
|
||
{
|
||
foreach ($this->plannersFor($input) as $planner) {
|
||
$refusal = $planner->rights();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return list<RestorePlanner> The planner of the snapshot's area, or every planner.
|
||
*/
|
||
private function plannersFor(array $input): array
|
||
{
|
||
$planner = $this->planners[self::areaOf($input) ?? ''] ?? null;
|
||
|
||
return $planner !== null ? [$planner] : array_values($this->planners);
|
||
}
|
||
|
||
/** @param array<string, mixed> $input */
|
||
private static function areaOf(array $input): ?string
|
||
{
|
||
$id = $input['snapshot_id'] ?? null;
|
||
|
||
return is_string($id) && preg_match(self::ID, $id, $match) === 1 ? $match[1] : null;
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php` – nur die Konstruktion ändert sich, Erwartungen bleiben:
|
||
|
||
*Import (nach `DiviDesignRestoreAbility`)* – vorher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner;
|
||
```
|
||
|
||
*`testRestoresWithDryRunAndASnapshotOfTheCurrentState()` (Zeile 40)* – vorher:
|
||
|
||
```php
|
||
$ability = new DiviDesignRestoreAbility($writer, new DesignChange($snapshots, new FakeChangeLog(8)), $snapshots);
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
$ability = new DiviDesignRestoreAbility(
|
||
new DesignChange($snapshots, new FakeChangeLog(8)),
|
||
$snapshots,
|
||
new PresetRestorePlanner($writer)
|
||
);
|
||
```
|
||
|
||
*`restoring()` (Zeilen 65–70)* – vorher:
|
||
|
||
```php
|
||
return new DiviDesignRestoreAbility(
|
||
$writer,
|
||
new DesignChange($snapshots, FakeChangeLog::forbidden()),
|
||
$snapshots,
|
||
static fn (): bool => $unfiltered
|
||
);
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
return new DiviDesignRestoreAbility(
|
||
new DesignChange($snapshots, FakeChangeLog::forbidden()),
|
||
$snapshots,
|
||
new PresetRestorePlanner($writer, static fn (): bool => $unfiltered)
|
||
);
|
||
```
|
||
|
||
*`testNeedsEditThemeOptions()` (Zeile 99) und `testUnknownSnapshotAndOtherAreas()` (Zeile 113) – beide Zeilen gleich, beide ersetzen* – vorher:
|
||
|
||
```php
|
||
$ability = new DiviDesignRestoreAbility($writer, new DesignChange($snapshots, FakeChangeLog::forbidden()), $snapshots);
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
$ability = new DiviDesignRestoreAbility(
|
||
new DesignChange($snapshots, FakeChangeLog::forbidden()),
|
||
$snapshots,
|
||
new PresetRestorePlanner($writer)
|
||
);
|
||
```
|
||
|
||
`src/Integrations/Divi/DiviIntegration.php` (`DesignRights` bleibt importiert, es wird weiter verwendet):
|
||
|
||
*Import (nach `PresetComposer`)* – vorher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner;
|
||
```
|
||
|
||
*Konstruktoraufruf (Zeilen 264–269)* – vorher:
|
||
|
||
```php
|
||
new DiviDesignRestoreAbility(
|
||
$this->designWriter,
|
||
$this->designChange(),
|
||
new SnapshotStore(),
|
||
DesignRights::unfilteredHtml()
|
||
),
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
new DiviDesignRestoreAbility(
|
||
$this->designChange(),
|
||
new SnapshotStore(),
|
||
new PresetRestorePlanner($this->designWriter, DesignRights::unfilteredHtml())
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 7: GREEN, Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignRestoreAbilityTest|PresetRestorePlannerTest|DiviToolFeaturesTest|DiviIntegrationTest/' && composer lint`
|
||
Expected: PASS (Restore-Tests 4, Tabelle unverändert), phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
Commit wie in den Global Constraints:
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/RestorePlanner.php \
|
||
src/Integrations/Divi/Design/PresetRestorePlanner.php \
|
||
src/Integrations/Divi/Design/DiviDesignRestoreAbility.php \
|
||
tests/Unit/Integrations/Divi/Design/PresetRestorePlannerTest.php \
|
||
tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php \
|
||
src/Integrations/Divi/DiviIntegration.php
|
||
git commit -m "refactor: lit/divi-design-restore plant über RestorePlanner; PresetRestorePlanner übernimmt den bisherigen Code unverändert" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 9b – `ColorRestorePlanner` und `VariableRestorePlanner`
|
||
|
||
- [ ] **Step 9: Failing Tests schreiben**
|
||
|
||
Die Fixtures sind öffentlich, weil die Ability-Tests (9c) sie wiederverwenden. Datei `tests/Unit/Integrations/Divi/Design/ColorRestorePlannerTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\ColorArea;
|
||
use LIT\MCP\Integrations\Divi\Design\ColorBook;
|
||
use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class ColorRestorePlannerTest extends TestCase
|
||
{
|
||
public const SNAPSHOT_ID = 'colors-20261016T100000Z-aaaaaa';
|
||
|
||
/** One stored color in ColorBook::data() form. */
|
||
public static function color(string $label, string $color): array
|
||
{
|
||
return ['color' => $color, 'folder' => '', 'label' => $label, 'lastUpdated' => '2026-10-01T10:00:00.000Z',
|
||
'status' => 'active', 'usedInPosts' => []];
|
||
}
|
||
|
||
/** @return array<string, array<string, mixed>> The colors now: "Marke" red, "Akzent" created after the snapshot. */
|
||
public static function current(): array
|
||
{
|
||
return [
|
||
'gcid-brand00001' => self::color('Marke', '#ff0000'),
|
||
'gcid-akzent0001' => self::color('Akzent', '#00ff00'),
|
||
];
|
||
}
|
||
|
||
/** @return array<string, array<string, mixed>> The snapshot: "Marke" blue, no "Akzent". */
|
||
public static function snapshot(): array
|
||
{
|
||
return ['gcid-brand00001' => self::color('Marke', '#0000ff')];
|
||
}
|
||
|
||
private static function planner(bool $visualBuilder = true): ColorRestorePlanner
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('canUseVisualBuilder')->andReturn($visualBuilder);
|
||
|
||
return new ColorRestorePlanner($writer, Mockery::mock(GlobalColorsAdapter::class));
|
||
}
|
||
|
||
public function testAreaFeaturesAndDesignArea(): void
|
||
{
|
||
$planner = self::planner();
|
||
|
||
self::assertSame('colors', $planner->area());
|
||
self::assertSame([DiviFeature::DesignRead, DiviFeature::ColorWrite], $planner->features());
|
||
self::assertInstanceOf(ColorArea::class, $planner->designArea());
|
||
}
|
||
|
||
public function testNeedsTheColorRights(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
self::assertNull(self::planner()->rights());
|
||
self::assertToolError(self::planner(false)->rights(), 'forbidden');
|
||
}
|
||
|
||
public function testTheSnapshotReplacesEveryColorAndNamesTheRemovedOnes(): void
|
||
{
|
||
$plan = self::planner()->plan(self::current(), self::snapshot(), self::SNAPSHOT_ID);
|
||
|
||
self::assertInstanceOf(DesignPlan::class, $plan);
|
||
self::assertTrue($plan->mayRemove);
|
||
self::assertSame(ColorBook::fromDivi(self::snapshot())->data(), $plan->raw);
|
||
self::assertSame(
|
||
[['gcid-brand00001', 'changed'], ['gcid-akzent0001', 'removed']],
|
||
array_map(static fn (array $row): array => [$row['id'], $row['action']], $plan->diff)
|
||
);
|
||
self::assertStringStartsWith('Snapshot ' . self::SNAPSHOT_ID . ' wiederherstellen – Farben: ', $plan->summary);
|
||
self::assertStringEndsWith(' Entfernt: 1.', $plan->summary);
|
||
self::assertSame(
|
||
['Farben, die es im Snapshot nicht gibt, entfallen („Akzent“); Seiten und Presets, die sie verwenden, '
|
||
. 'behalten den Verweis ohne Wert.'],
|
||
$plan->warnings
|
||
);
|
||
}
|
||
|
||
public function testTheSameColorsAreNoChange(): void
|
||
{
|
||
$plan = self::planner()->plan(self::snapshot(), self::snapshot(), self::SNAPSHOT_ID);
|
||
|
||
self::assertInstanceOf(DesignPlan::class, $plan);
|
||
self::assertTrue($plan->isEmpty());
|
||
self::assertSame([], $plan->warnings);
|
||
}
|
||
}
|
||
```
|
||
|
||
Datei `tests/Unit/Integrations/Divi/Design/VariableRestorePlannerTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableArea;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableBook;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class VariableRestorePlannerTest extends TestCase
|
||
{
|
||
public const SNAPSHOT_ID = 'variables-20261016T100000Z-aaaaaa';
|
||
|
||
/** One stored variable of type numbers. */
|
||
public static function number(string $id, string $label, string $value): array
|
||
{
|
||
return ['id' => $id, 'label' => $label, 'value' => $value, 'order' => '1', 'status' => 'active',
|
||
'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => 'numbers'];
|
||
}
|
||
|
||
/** @return array<string, array<string, array<string, mixed>>> Now: radius 8px and "Neu", created afterwards. */
|
||
public static function current(): array
|
||
{
|
||
return ['numbers' => [
|
||
'gvid-radius0001' => self::number('gvid-radius0001', 'Eckenradius', '8px'),
|
||
'gvid-neu0000001' => self::number('gvid-neu0000001', 'Neu', '2rem'),
|
||
]];
|
||
}
|
||
|
||
/** @return array<string, array<string, array<string, mixed>>> The snapshot: radius 4px. */
|
||
public static function snapshot(): array
|
||
{
|
||
return ['numbers' => ['gvid-radius0001' => self::number('gvid-radius0001', 'Eckenradius', '4px')]];
|
||
}
|
||
|
||
private static function planner(bool $manage = true): VariableRestorePlanner
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$variables = Mockery::mock(GlobalVariablesAdapter::class);
|
||
$variables->allows('canManage')->andReturn($manage);
|
||
|
||
return new VariableRestorePlanner($writer, $variables);
|
||
}
|
||
|
||
public function testAreaFeaturesAndDesignArea(): void
|
||
{
|
||
$planner = self::planner();
|
||
|
||
self::assertSame('variables', $planner->area());
|
||
self::assertSame([DiviFeature::DesignRead, DiviFeature::VariableWrite], $planner->features());
|
||
self::assertInstanceOf(VariableArea::class, $planner->designArea());
|
||
}
|
||
|
||
public function testNeedsTheVariableRights(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
self::assertNull(self::planner()->rights());
|
||
self::assertToolError(self::planner(false)->rights(), 'forbidden');
|
||
}
|
||
|
||
public function testRestoresTheSnapshotAndKeepsNewerVariablesArchived(): void
|
||
{
|
||
$current = VariableBook::fromDivi(self::current());
|
||
$snapshot = VariableBook::fromDivi(self::snapshot());
|
||
|
||
$plan = self::planner()->plan(self::current(), self::snapshot(), self::SNAPSHOT_ID);
|
||
|
||
self::assertInstanceOf(DesignPlan::class, $plan);
|
||
self::assertFalse($plan->mayRemove, 'V1: variables are never removed');
|
||
self::assertSame($current->restoredFrom($snapshot)->data(), $plan->raw);
|
||
self::assertSame('4px', $plan->raw['numbers']['gvid-radius0001']['value']);
|
||
self::assertSame('archived', $plan->raw['numbers']['gvid-neu0000001']['status']);
|
||
self::assertEqualsCanonicalizing(
|
||
[['gvid-radius0001', 'changed'], ['gvid-neu0000001', 'archived']],
|
||
array_map(static fn (array $row): array => [$row['id'], $row['action']], $plan->diff)
|
||
);
|
||
self::assertStringStartsWith('Snapshot ' . self::SNAPSHOT_ID . ' wiederherstellen – Variablen: ', $plan->summary);
|
||
self::assertSame(
|
||
['Variable „Neu“ gibt es im Snapshot nicht; sie bleibt archiviert erhalten (Divi löscht keine Variablen).'],
|
||
$plan->warnings
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 10: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/ColorRestorePlannerTest|VariableRestorePlannerTest/'`
|
||
Expected: FAIL – `Error: Class "LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner" not found` bzw. `… VariableRestorePlanner" not found` (7 Fehler).
|
||
|
||
- [ ] **Step 11: `ColorRestorePlanner` implementieren**
|
||
|
||
Zeilen von `ColorDiff::between()` zu IDs, die der Snapshot nicht kennt, filtert der Planer heraus und ersetzt sie durch eigene `removed`-Zeilen – so hängt das Ergebnis nicht davon ab, wie `ColorDiff` fehlende Einträge behandelt. Datei `src/Integrations/Divi/Design/ColorRestorePlanner.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Restores a snapshot of the global colors (ruling V10): the snapshot replaces every stored color
|
||
* (set_global_colors($data, false), ruling V4) – colors created after the snapshot are removed. ColorDiff knows no
|
||
* removal, so the planner adds those rows ("removed") itself and warns that pages keep their references.
|
||
*/
|
||
final class ColorRestorePlanner implements RestorePlanner
|
||
{
|
||
private const REMOVED = 'Farben, die es im Snapshot nicht gibt, entfallen (%s); Seiten und Presets, die sie '
|
||
. 'verwenden, behalten den Verweis ohne Wert.';
|
||
|
||
public function __construct(private DesignSystemWriter $writer, private GlobalColorsAdapter $colors)
|
||
{
|
||
}
|
||
|
||
public function area(): string
|
||
{
|
||
return 'colors';
|
||
}
|
||
|
||
public function designArea(): DesignArea
|
||
{
|
||
return new ColorArea($this->colors);
|
||
}
|
||
|
||
public function rights(): ?WP_Error
|
||
{
|
||
return DesignRights::colors($this->writer);
|
||
}
|
||
|
||
/** Writes the colors (ColorWrite), reading the design system first (DesignRead). */
|
||
public function features(): array
|
||
{
|
||
return [DiviFeature::DesignRead, DiviFeature::ColorWrite];
|
||
}
|
||
|
||
public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error
|
||
{
|
||
$before = ColorBook::fromDivi($current);
|
||
$after = ColorBook::fromDivi($snapshot);
|
||
$kept = array_values(array_filter(
|
||
ColorDiff::between($before, $after),
|
||
static fn (array $row): bool => $after->entry($row['id']) !== null
|
||
));
|
||
$removed = [];
|
||
foreach (array_keys(array_diff_key($before->data(), $after->data())) as $id) {
|
||
$removed[] = ['id' => (string) $id, 'name' => $before->label((string) $id), 'action' => 'removed'];
|
||
}
|
||
|
||
$summary = sprintf('Snapshot %s wiederherstellen – %s', $snapshotId, ColorDiff::summary($kept));
|
||
$warnings = [];
|
||
if ($removed !== []) {
|
||
$summary .= sprintf(' Entfernt: %d.', count($removed));
|
||
$warnings[] = sprintf(
|
||
self::REMOVED,
|
||
implode(', ', array_map(static fn (array $row): string => '„' . $row['name'] . '“', $removed))
|
||
);
|
||
}
|
||
|
||
return new DesignPlan($after->data(), array_merge($kept, $removed), $summary, null, $warnings, true);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 12: `VariableRestorePlanner` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Design/VariableRestorePlanner.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Restores a snapshot of the global variables (rulings V1b, V10): every variable of the snapshot comes back as it was;
|
||
* Divi refuses to drop a stored variable, so variables created after the snapshot stay – archived
|
||
* (VariableBook::restoredFrom()) – and the dry run names them as a warning. Nothing is removed.
|
||
*/
|
||
final class VariableRestorePlanner implements RestorePlanner
|
||
{
|
||
private const KEPT = 'Variable „%s“ gibt es im Snapshot nicht; sie bleibt archiviert erhalten (Divi löscht keine '
|
||
. 'Variablen).';
|
||
|
||
public function __construct(private DesignSystemWriter $writer, private GlobalVariablesAdapter $variables)
|
||
{
|
||
}
|
||
|
||
public function area(): string
|
||
{
|
||
return 'variables';
|
||
}
|
||
|
||
public function designArea(): DesignArea
|
||
{
|
||
return new VariableArea($this->variables);
|
||
}
|
||
|
||
public function rights(): ?WP_Error
|
||
{
|
||
return DesignRights::variables($this->writer, $this->variables);
|
||
}
|
||
|
||
/** Writes the variables (VariableWrite), reading the design system first (DesignRead). */
|
||
public function features(): array
|
||
{
|
||
return [DiviFeature::DesignRead, DiviFeature::VariableWrite];
|
||
}
|
||
|
||
public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error
|
||
{
|
||
$before = VariableBook::fromDivi($current);
|
||
$older = VariableBook::fromDivi($snapshot);
|
||
$after = $before->restoredFrom($older);
|
||
$diff = VariableDiff::between($before, $after);
|
||
|
||
return new DesignPlan(
|
||
$after->data(),
|
||
$diff,
|
||
sprintf('Snapshot %s wiederherstellen – %s', $snapshotId, VariableDiff::summary($diff)),
|
||
null,
|
||
array_map(static fn (array $row): string => sprintf(self::KEPT, $row['label']), $before->onlyHere($older))
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 13: GREEN, Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/ColorRestorePlannerTest|VariableRestorePlannerTest/' && composer lint`
|
||
Expected: `OK (7 tests, …)`, phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 14: Commit**
|
||
|
||
Commit wie in den Global Constraints:
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/ColorRestorePlanner.php \
|
||
src/Integrations/Divi/Design/VariableRestorePlanner.php \
|
||
tests/Unit/Integrations/Divi/Design/ColorRestorePlannerTest.php \
|
||
tests/Unit/Integrations/Divi/Design/VariableRestorePlannerTest.php
|
||
git commit -m "feat: ColorRestorePlanner und VariableRestorePlanner – Restore-Pläne für Farben und Variablen (V1b, V10)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 9c – Restore für Farben und Variablen freischalten
|
||
|
||
- [ ] **Step 15: Tests ergänzen**
|
||
|
||
`tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php`: Probelauf und `confirm` für Farben und Variablen, Rechte und Fähigkeiten je Bereich, `theme_options` als einziger nicht unterstützter Bereich. Die Adapter-Doubles speichern, was `save()` bekommt, und geben es bei `read()` zurück (Zurücklesen der Pipeline).
|
||
|
||
*Importe (Zeilen 8–14 nach Task 9 Step 6)* – vorher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\ColorBook;
|
||
use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use Mockery\MockInterface;
|
||
```
|
||
|
||
*`testUnknownSnapshotAndOtherAreas()` (ganze Methode, Stand nach Task 9 Step 6) wird ersetzt* – vorher:
|
||
|
||
```php
|
||
public function testUnknownSnapshotAndOtherAreas(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->allows('get')->andReturn(null);
|
||
$ability = new DiviDesignRestoreAbility(
|
||
new DesignChange($snapshots, FakeChangeLog::forbidden()),
|
||
$snapshots,
|
||
new PresetRestorePlanner($writer)
|
||
);
|
||
|
||
self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-abcdef']), 'not_found');
|
||
self::assertToolError($ability->execute(['snapshot_id' => 'colors-20261016T100000Z-abcdef']), 'unsupported');
|
||
self::assertToolError($ability->execute(['snapshot_id' => 'kaputt']), 'validation_failed');
|
||
}
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
public function testUnknownSnapshotAndThemeOptions(): void
|
||
{
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->allows('get')->andReturn(null);
|
||
$ability = self::allAreas($snapshots, self::colorStore([]), self::variableStore([]));
|
||
|
||
self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-abcdef']), 'not_found');
|
||
self::assertToolError($ability->execute(['snapshot_id' => 'colors-20261016T100000Z-abcdef']), 'not_found');
|
||
self::assertToolError($ability->execute(['snapshot_id' => 'variables-20261016T100000Z-abcdef']), 'not_found');
|
||
$themeOptions = self::assertToolError(
|
||
$ability->execute(['snapshot_id' => 'theme_options-20261016T100000Z-abcdef']),
|
||
'unsupported'
|
||
);
|
||
self::assertSame('Theme-Optionen folgen mit Etappe 4d.', $themeOptions['hint']);
|
||
self::assertToolError($ability->execute(['snapshot_id' => 'kaputt']), 'validation_failed');
|
||
}
|
||
|
||
/** The ability with the planners of all three areas; the user has every right unless $manage is false. */
|
||
private static function allAreas(
|
||
SnapshotStore $snapshots,
|
||
GlobalColorsAdapter $colors,
|
||
GlobalVariablesAdapter $variables,
|
||
?FakeChangeLog $log = null
|
||
): DiviDesignRestoreAbility {
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$writer->allows('presets')->andReturn(PresetBookTest::stored());
|
||
$writer->shouldNotReceive('savePresets');
|
||
|
||
return new DiviDesignRestoreAbility(
|
||
new DesignChange($snapshots, $log ?? FakeChangeLog::forbidden()),
|
||
$snapshots,
|
||
new PresetRestorePlanner($writer),
|
||
new ColorRestorePlanner($writer, $colors),
|
||
new VariableRestorePlanner($writer, $variables)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A colors adapter that reads what it last saved, starting with $stored.
|
||
*
|
||
* @param array<string, array<string, mixed>> $stored
|
||
*/
|
||
private static function colorStore(array $stored): GlobalColorsAdapter&MockInterface
|
||
{
|
||
$colors = Mockery::mock(GlobalColorsAdapter::class);
|
||
$colors->allows('read')->andReturnUsing(static function () use (&$stored): array {
|
||
return $stored;
|
||
});
|
||
$colors->allows('save')->andReturnUsing(static function (array $entries, bool $replace) use (&$stored): void {
|
||
self::assertTrue($replace, 'a restore replaces every color (V4)');
|
||
$stored = $entries;
|
||
});
|
||
|
||
return $colors;
|
||
}
|
||
|
||
/**
|
||
* A variables adapter that reads what it last saved, starting with $stored.
|
||
*
|
||
* @param array<string, array<string, array<string, mixed>>> $stored
|
||
*/
|
||
private static function variableStore(array $stored, bool $manage = true): GlobalVariablesAdapter&MockInterface
|
||
{
|
||
$variables = Mockery::mock(GlobalVariablesAdapter::class);
|
||
$variables->allows('canManage')->andReturn($manage);
|
||
$variables->allows('read')->andReturnUsing(static function () use (&$stored): array {
|
||
return $stored;
|
||
});
|
||
$variables->allows('save')->andReturnUsing(static function (array $data) use (&$stored): void {
|
||
$stored = $data;
|
||
});
|
||
|
||
return $variables;
|
||
}
|
||
|
||
/** @param array<array-key, mixed> $data */
|
||
private static function snapshotIn(string $area, string $id, array $data): array
|
||
{
|
||
return ['id' => $id, 'data' => $data, 'created' => 'x', 'user_id' => 1, 'ability' => 'lit/divi-' . $area . '-set',
|
||
'log_id' => 2, 'size' => 1];
|
||
}
|
||
|
||
public function testRestoresColorsWithDryRunAndASnapshotOfTheCurrentColors(): void
|
||
{
|
||
$id = ColorRestorePlannerTest::SNAPSHOT_ID;
|
||
$colors = self::colorStore(ColorRestorePlannerTest::current());
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->allows('get')->with('colors', $id)
|
||
->andReturn(self::snapshotIn('colors', $id, ColorRestorePlannerTest::snapshot()));
|
||
$snapshots->expects('save')
|
||
->with('colors', ColorBook::fromDivi(ColorRestorePlannerTest::current())->data(), 'lit/divi-design-restore')
|
||
->andReturn('colors-5');
|
||
$snapshots->expects('attachLog');
|
||
$ability = self::allAreas($snapshots, $colors, self::variableStore([]), new FakeChangeLog(8));
|
||
|
||
$dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required');
|
||
self::assertSame(['changed', 'removed'], array_column($dry['diff'], 'action'));
|
||
self::assertStringContainsString('„Akzent“', $dry['warnings'][0]);
|
||
|
||
$result = $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||
self::assertSame('colors-5', $result['snapshot_id']);
|
||
self::assertSame(ColorBook::fromDivi(ColorRestorePlannerTest::snapshot())->data(), $colors->read());
|
||
}
|
||
|
||
public function testRestoresVariablesAndKeepsNewerOnesArchived(): void
|
||
{
|
||
$id = VariableRestorePlannerTest::SNAPSHOT_ID;
|
||
$variables = self::variableStore(VariableRestorePlannerTest::current());
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->allows('get')->with('variables', $id)
|
||
->andReturn(self::snapshotIn('variables', $id, VariableRestorePlannerTest::snapshot()));
|
||
$snapshots->expects('save')
|
||
->with('variables', Mockery::type('array'), 'lit/divi-design-restore')
|
||
->andReturn('variables-5');
|
||
$snapshots->expects('attachLog');
|
||
$ability = self::allAreas($snapshots, self::colorStore([]), $variables, new FakeChangeLog(8));
|
||
|
||
$dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required');
|
||
self::assertStringContainsString('„Neu“', $dry['warnings'][0]);
|
||
|
||
$result = $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||
self::assertSame('variables-5', $result['snapshot_id']);
|
||
$stored = $variables->read();
|
||
self::assertSame('4px', $stored['numbers']['gvid-radius0001']['value']);
|
||
self::assertSame('archived', $stored['numbers']['gvid-neu0000001']['status']);
|
||
}
|
||
|
||
/** Spec §10, V10: the rights are those of the snapshot's area. */
|
||
public function testRightsFollowTheSnapshotArea(): void
|
||
{
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->shouldNotReceive('get');
|
||
$ability = self::allAreas($snapshots, self::colorStore([]), self::variableStore([], false));
|
||
|
||
self::assertToolError(
|
||
$ability->permission(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]),
|
||
'forbidden'
|
||
);
|
||
self::assertToolError(
|
||
$ability->execute(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]),
|
||
'forbidden'
|
||
);
|
||
self::assertTrue($ability->permission(['snapshot_id' => ColorRestorePlannerTest::SNAPSHOT_ID]));
|
||
self::assertTrue($ability->permission(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa']));
|
||
self::assertToolError($ability->permission(['snapshot_id' => 'kaputt']), 'forbidden', 'fail closed');
|
||
}
|
||
|
||
public function testFeaturesFollowTheSnapshotArea(): void
|
||
{
|
||
$ability = self::allAreas(Mockery::mock(SnapshotStore::class), self::colorStore([]), self::variableStore([]));
|
||
|
||
self::assertSame(
|
||
[DiviFeature::DesignRead, DiviFeature::ColorWrite],
|
||
$ability->diviFeatures(['snapshot_id' => ColorRestorePlannerTest::SNAPSHOT_ID])
|
||
);
|
||
self::assertSame(
|
||
[DiviFeature::DesignRead, DiviFeature::VariableWrite],
|
||
$ability->diviFeatures(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID])
|
||
);
|
||
self::assertSame(
|
||
[DiviFeature::DesignRead, DiviFeature::PresetWrite],
|
||
$ability->diviFeatures(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa'])
|
||
);
|
||
self::assertSame(
|
||
[DiviFeature::DesignRead, DiviFeature::PresetWrite, DiviFeature::ColorWrite, DiviFeature::VariableWrite],
|
||
$ability->diviFeatures([])
|
||
);
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php`:
|
||
|
||
*Tabelle `EXPECTED` (Zeile 59)* – vorher:
|
||
|
||
```php
|
||
'lit/divi-design-restore' => ['design-read', 'preset-write'],
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
'lit/divi-design-restore' => ['design-read', 'preset-write', 'color-write', 'variable-write'],
|
||
```
|
||
|
||
*`inputCases()`, vor `'library-list'`* – vorher:
|
||
|
||
```php
|
||
'library-list' => ['lit/library-list', [], ['library-read', 'roles']],
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
'design-restore of presets' => [
|
||
'lit/divi-design-restore', ['snapshot_id' => 'presets-20261016T100000Z-aaaaaa'],
|
||
['design-read', 'preset-write'],
|
||
],
|
||
'design-restore of colors' => [
|
||
'lit/divi-design-restore', ['snapshot_id' => 'colors-20261016T100000Z-aaaaaa'],
|
||
['design-read', 'color-write'],
|
||
],
|
||
'design-restore of variables' => [
|
||
'lit/divi-design-restore', ['snapshot_id' => 'variables-20261016T100000Z-aaaaaa'],
|
||
['design-read', 'variable-write'],
|
||
],
|
||
'library-list' => ['lit/library-list', [], ['library-read', 'roles']],
|
||
```
|
||
|
||
- [ ] **Step 16: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignRestoreAbilityTest|DiviToolFeaturesTest/'`
|
||
Expected: FAIL – `testUnknownSnapshotAndThemeOptions` (Hinweis noch „Farben und Variablen folgen mit Etappe 4c, Theme-Optionen mit 4d.“), `testEveryToolDeclaresExactlyTheTableForAnInputThatNamesNothing` (`lit/divi-design-restore`: `color-write`, `variable-write` fehlen) und `testInputDependentFeatures` für „design-restore of colors/variables“ (DiviIntegration kennt nur den Presets-Planer). Die übrigen neuen Ability-Tests laufen schon, weil 9a die Ability allgemein gemacht hat.
|
||
|
||
- [ ] **Step 17: Ability-Texte und Verdrahtung der Planer**
|
||
|
||
`src/Integrations/Divi/Design/DiviDesignRestoreAbility.php`:
|
||
|
||
*Klassen-Docblock* – vorher:
|
||
|
||
```php
|
||
* the plan. Areas without a planner are not restorable yet.
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
* the plan. Areas without a planner (theme_options until Etappe 4d) are not restorable yet.
|
||
```
|
||
|
||
*`description()`* – vorher:
|
||
|
||
```php
|
||
. 'beim Nutzer confirm: true; vorher wird der aktuelle Stand selbst gesichert.';
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
. 'beim Nutzer confirm: true; vorher wird der aktuelle Stand selbst gesichert. Bereiche: presets, colors '
|
||
. '(Farben, die es im Snapshot nicht gibt, entfallen) und variables (Variablen, die erst nach dem Snapshot '
|
||
. 'entstanden, bleiben archiviert erhalten).';
|
||
```
|
||
|
||
*Hinweis für Bereiche ohne Planer in `execute()`* – vorher:
|
||
|
||
```php
|
||
'Farben und Variablen folgen mit Etappe 4c, Theme-Optionen mit 4d.'
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
'Theme-Optionen folgen mit Etappe 4d.'
|
||
```
|
||
|
||
`src/Integrations/Divi/DiviIntegration.php` (die Adapter entstehen hier wie `SnapshotStore` inline; teilt Task 10 eine Instanz je Adapter, ersetzt es diese beiden `new`):
|
||
|
||
*Import (vor `DesignSystemReader`)* – vorher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||
```
|
||
|
||
*Import (vor `GlobalVariablesAdapter`, in Task 8 ergänzt)* – vorher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
|
||
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
|
||
```
|
||
|
||
*Import (nach `SnapshotStore`)* – vorher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner;
|
||
```
|
||
|
||
*Konstruktoraufruf (aus Task 9 Step 6)* – vorher:
|
||
|
||
```php
|
||
new PresetRestorePlanner($this->designWriter, DesignRights::unfilteredHtml())
|
||
),
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
new PresetRestorePlanner($this->designWriter, DesignRights::unfilteredHtml()),
|
||
new ColorRestorePlanner($this->designWriter, new GlobalColorsAdapter()),
|
||
new VariableRestorePlanner($this->designWriter, new GlobalVariablesAdapter())
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 18: GREEN, Gesamtlauf, Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviDesignRestoreAbilityTest|DiviToolFeaturesTest|DiviIntegrationTest/'`
|
||
Expected: PASS (`DiviDesignRestoreAbilityTest` 8 Tests, `DiviToolFeaturesTest` 38).
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests grün, phpcs ohne Meldung.
|
||
|
||
- [ ] **Step 19: Commit**
|
||
|
||
Commit wie in den Global Constraints:
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DiviDesignRestoreAbility.php \
|
||
tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php \
|
||
src/Integrations/Divi/DiviIntegration.php \
|
||
tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php
|
||
git commit -m "feat: lit/divi-design-restore stellt Farben und Variablen wieder her; nur theme_options folgt mit 4d (V10)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
|
||
### Task 10: Verdrahtung in `DiviIntegration` und Tabellen der Fähigkeiten
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php`
|
||
- Modify: `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` (Namensliste)
|
||
- Modify: `tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php` (Tabelle `EXPECTED`, Zeile ca. 331 `inputCases`/Schreib-Fähigkeit je Tool)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DiviColorsSetAbility(DesignSystemWriter, GlobalColorsAdapter, DesignChange, GlobalUsages, ?Closure $random = null)` und `DiviVariablesSetAbility(DesignSystemWriter, GlobalVariablesAdapter, DesignChange, GlobalUsages, ?Closure $random = null)` (Tasks 6/7); `GlobalColorsAdapter`/`GlobalVariablesAdapter` ohne Konstruktorargumente (Task 2).
|
||
- Produces: beide Tools in `DiviIntegration::abilities()`, **eine** Adapter-Instanz je Integration (Eigenschaften `$colorsAdapter`, `$variablesAdapter`). Die Konstruktoraufrufe von `lit/divi-design-detach` (Task 8, Step 18) und `lit/divi-design-restore` (Task 9) haben dort inline `new GlobalVariablesAdapter()`/`new GlobalColorsAdapter()` eingeführt; dieser Task ersetzt sie durch die geteilten Eigenschaften. Die Fähigkeiten-Zeilen von `lit/divi-design-detach`/`-restore` in `DiviToolFeaturesTest` haben die Tasks 8/9 bereits angepasst und bleiben unberührt.
|
||
|
||
- [ ] **Step 1: Tests zuerst – Namensliste und Fähigkeitentabelle**
|
||
|
||
`tests/Unit/Integrations/Divi/DiviIntegrationTest.php`, in `EXPECTED` nach `'lit/divi-design-detach',` ergänzen (die Tools stehen am Ende von `abilities()`):
|
||
|
||
```php
|
||
'lit/divi-colors-set',
|
||
'lit/divi-variables-set',
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php`, in `EXPECTED` unter dem Kommentar `// Design system (divi)` nach der Zeile `'lit/divi-design-detach' => [ … ],` ergänzen:
|
||
|
||
```php
|
||
'lit/divi-colors-set' => ['design-read', 'color-write'],
|
||
'lit/divi-variables-set' => ['design-read', 'variable-write'],
|
||
```
|
||
|
||
Und in der zweiten Tabelle der Datei (Zeile mit `'lit/divi-presets-set' => 'preset-write', 'lit/divi-presets-delete' => 'preset-write',`, die jedem schreibenden Tool seine Schreib-Fähigkeit zuordnet) ergänzen:
|
||
|
||
```php
|
||
'lit/divi-colors-set' => 'color-write', 'lit/divi-variables-set' => 'variable-write',
|
||
```
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviIntegrationTest|DiviToolFeaturesTest/'`
|
||
Expected: FAIL – `DiviIntegrationTest` (die Namensliste enthält die zwei Tools nicht) und `DiviToolFeaturesTest` (die Tabelle nennt Tools, die es nicht gibt).
|
||
|
||
- [ ] **Step 2: Imports, Eigenschaften und Konstruktor**
|
||
|
||
`src/Integrations/Divi/DiviIntegration.php` – Imports (alphabetisch in die vorhandene Reihe der `Design\`-Imports einsortieren; `GlobalColorsAdapter` und `GlobalVariablesAdapter` stehen nach Task 8/9 schon dort):
|
||
|
||
```php
|
||
use LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\DiviVariablesSetAbility;
|
||
```
|
||
|
||
Eigenschaften neben `private DesignSystemWriter $designWriter;`:
|
||
|
||
```php
|
||
private GlobalColorsAdapter $colorsAdapter;
|
||
|
||
private GlobalVariablesAdapter $variablesAdapter;
|
||
```
|
||
|
||
Im Konstruktor nach `$this->designWriter = new DesignSystemWriter();`:
|
||
|
||
```php
|
||
$this->colorsAdapter = new GlobalColorsAdapter();
|
||
$this->variablesAdapter = new GlobalVariablesAdapter();
|
||
```
|
||
|
||
- [ ] **Step 3: Tools registrieren, geteilte Adapter verwenden**
|
||
|
||
In `abilities()` die beiden inline-`new` aus Task 8/9 ersetzen und die neuen Tools ans Ende der Liste hängen. Vorher (Restore, nach Task 9):
|
||
|
||
```php
|
||
new ColorRestorePlanner($this->designWriter, new GlobalColorsAdapter()),
|
||
new VariableRestorePlanner($this->designWriter, new GlobalVariablesAdapter())
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
new ColorRestorePlanner($this->designWriter, $this->colorsAdapter),
|
||
new VariableRestorePlanner($this->designWriter, $this->variablesAdapter)
|
||
```
|
||
|
||
Vorher (Detach, nach Task 8):
|
||
|
||
```php
|
||
$this->designWriter,
|
||
new GlobalVariablesAdapter(),
|
||
$this->designChange(),
|
||
```
|
||
|
||
nachher:
|
||
|
||
```php
|
||
$this->designWriter,
|
||
$this->variablesAdapter,
|
||
$this->designChange(),
|
||
```
|
||
|
||
Nach dem Eintrag `new DiviDesignDetachAbility( … ),` (letzter Eintrag der Liste) anfügen:
|
||
|
||
```php
|
||
new DiviColorsSetAbility(
|
||
$this->designWriter,
|
||
$this->colorsAdapter,
|
||
$this->designChange(),
|
||
$this->globalUsages()
|
||
),
|
||
new DiviVariablesSetAbility(
|
||
$this->designWriter,
|
||
$this->variablesAdapter,
|
||
$this->designChange(),
|
||
$this->globalUsages()
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 4: GREEN, Gesamtlauf, Lint**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviIntegrationTest|DiviToolFeaturesTest|IntegrationRegistryTest|ToolNamingTest/'`
|
||
Expected: PASS.
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests grün, phpcs ohne Befund. Schlägt ein Test fehl, der Tool-Namen oder Beschreibungen prüft (`ToolNamingTest`, Exposure-/Guide-Tests), zuerst lesen, was er verlangt (indirekte Tools heißen in Texten `lit/<name>`, nicht `lit-<name>`), und den Text der Ability anpassen, nicht den Test.
|
||
|
||
- [ ] **Step 5: Die eine falsche Zeile im Guide berichtigen**
|
||
|
||
`docs/guides/divi-design-system.md` Zeile 128 behauptet, `variable` werde nicht unterstützt. Ersetzen:
|
||
|
||
Alt: `- Variablen: \`variable\` wird noch nicht unterstützt (\`unsupported\`; Variablen folgen mit Etappe 4c).`
|
||
Neu: `- Variablen: \`variable\` (Name oder ID) ersetzt jeden ganzen Verweis auf die Variable durch ihren aktuellen Wert, auch in Presets; danach lässt sie sich mit \`lit/divi-variables-set\` archivieren (löschen kann Divi sie nicht).`
|
||
|
||
Run: `grep -rn "folgen mit Etappe 4c\|Variablen folgen" src docs/guides`
|
||
Expected: keine Treffer. Die übrigen Guides (Farben/Variablen schreiben, Reihenfolge) folgen gesammelt mit 4e (Spec §14 Schritt 9); Treffer zu 4d (Theme-Optionen) bleiben.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php \
|
||
tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php docs/guides/divi-design-system.md
|
||
git commit -m "feat: lit/divi-colors-set und lit/divi-variables-set in der Divi-Integration; je ein geteilter Farben- und Variablen-Adapter" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
|
||
### Task 11: Smoke gegen echtes Divi, Formate-Nachtrag, Spec und Notizen
|
||
|
||
**Files:**
|
||
- Create: `tests/Integration/divi-colors-variables-smoke.sh`
|
||
- Modify: `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` (Nachtrag 5.15.1 für Farben/Variablen, direkt nach dem Nachtrag „Divi 5.15.1 (Etappe 4a …)“)
|
||
- Modify: `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` (§7.2)
|
||
- Modify: `docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md` (Abschnitt „Offen“)
|
||
|
||
**Interfaces:**
|
||
- Consumes: alle Tools aus den Tasks 6–9 über den OAuth-Endpunkt (`lit/divi-colors-set`, `lit/divi-variables-set`, `lit/divi-design-detach`, `lit/divi-design-restore`, `lit/divi-design-snapshots`) und die Hilfen aus `tests/Integration/lib.sh` (`connect`, `ability`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`, `cleanup_app_passwords_for`).
|
||
- Produces: ein Skript, das 4c gegen das echte Divi 5.15.1 prüft und alles, was es ändert, im EXIT-Trap wiederherstellt.
|
||
|
||
- [ ] **Step 1: Das Smoke-Skript anlegen**
|
||
|
||
Datei `tests/Integration/divi-colors-variables-smoke.sh`:
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# End-to-end checks of Etappe 4c (global colors and variables: create, relative color, cycle, main color, archive,
|
||
# detach on a page, snapshots, restore) through the OAuth-protected endpoint against the real Divi. Runs INSIDE the
|
||
# DDEV web container:
|
||
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-colors-variables-smoke.sh
|
||
# Creates colors/variables named "LIT Smoke <run> …" and one draft page. The EXIT trap puts the options et_divi (colors,
|
||
# customizer colors) and et_divi_global_variables back to what they were and deletes the page by exact ID.
|
||
set -euo pipefail
|
||
cd /var/www/html
|
||
# shellcheck source=lib.sh
|
||
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
|
||
|
||
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
|
||
RUN="$(date +%s)-$$"
|
||
PREFIX="LIT Smoke $RUN"
|
||
ORIGINAL_THEME=$(wp option get et_divi --format=json 2>/dev/null || echo '{}')
|
||
ORIGINAL_VARIABLES=$(wp option get et_divi_global_variables --format=json 2>/dev/null || echo '{}')
|
||
PAGE_ID=""
|
||
|
||
cleanup() {
|
||
printf '%s' "$LIT_THEME" | wp option update et_divi --format=json >/dev/null 2>&1 \
|
||
|| echo "WARN: et_divi not restored"
|
||
printf '%s' "$LIT_VARIABLES" | wp option update et_divi_global_variables --format=json >/dev/null 2>&1 \
|
||
|| echo "WARN: global variables not restored"
|
||
wp eval 'ET_Core_PageResource::remove_static_resources("all", "all", true);' >/dev/null 2>&1 || true
|
||
if [ -n "$PAGE_ID" ]; then wp post delete "$PAGE_ID" --force >/dev/null 2>&1 || true; fi
|
||
cleanup_app_passwords_for "$ADMIN_LOGIN"
|
||
}
|
||
export LIT_THEME="$ORIGINAL_THEME" LIT_VARIABLES="$ORIGINAL_VARIABLES"
|
||
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
|
||
|
||
connect "$ADMIN_LOGIN"
|
||
|
||
# confirm <tool> <input JSON>: dry run must answer confirm_required; then the confirmed call; prints the result.
|
||
confirm() {
|
||
local dry rev
|
||
dry=$(ability "$1" "$2")
|
||
[ "$(error_code "$dry")" = "confirm_required" ] || fail "$1 dry run: $dry"
|
||
rev=$(echo "$dry" | jq -r '.__error | fromjson | .design_rev')
|
||
ability "$1" "$(echo "$2" | jq -c --arg r "$rev" '. + {confirm: true, design_rev: $r}')"
|
||
}
|
||
stored_color() { # $1 = label -> the stored color value
|
||
wp eval "foreach (ET\Builder\Packages\GlobalData\GlobalData::get_global_colors() as \$c) { if ((\$c['label'] ?? '') === '$1') { echo \$c['color']; } }"
|
||
}
|
||
color_id() { # $1 = label
|
||
wp eval "foreach (ET\Builder\Packages\GlobalData\GlobalData::get_global_colors() as \$id => \$c) { if ((\$c['label'] ?? '') === '$1') { echo \$id; } }"
|
||
}
|
||
variable_field() { # $1 = label, $2 = field
|
||
wp eval "foreach (ET\Builder\Packages\GlobalData\GlobalData::get_global_variables() as \$items) { foreach ((array) \$items as \$v) { \$v = (array) \$v; if ((\$v['label'] ?? '') === '$1') { echo \$v['$2'] ?? ''; } } }"
|
||
}
|
||
|
||
# 1. Create a color: dry run, confirm, stale design_rev, stored in Divi.
|
||
NEW=$(jq -cn --arg l "$PREFIX Holz" '{items: [{label: $l, color: "#5b3a29"}]}')
|
||
DONE=$(confirm lit/divi-colors-set "$NEW")
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "colors create: $DONE"
|
||
COLORS_SNAPSHOT=$(echo "$DONE" | jq -r '.snapshot_id')
|
||
[ "$(stored_color "$PREFIX Holz")" = "#5b3a29" ] || fail "color not stored"
|
||
STALE=$(ability lit/divi-colors-set "$(echo "$NEW" | jq -c '.items[0].label += " 2" | . + {confirm: true, design_rev: "veraltet"}')")
|
||
[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale design_rev: $STALE"
|
||
pass "color created ($COLORS_SNAPSHOT), stale design_rev refused"
|
||
|
||
# 2. A relative color from a placeholder; a cycle is refused.
|
||
REL=$(jq -cn --arg l "$PREFIX Holz hell" --arg b "{{color:$PREFIX Holz}}" \
|
||
'{items: [{label: $l, color: $b, relative: {lightness: 20}}]}')
|
||
DONE=$(confirm lit/divi-colors-set "$REL")
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "relative color: $DONE"
|
||
BASE_ID=$(color_id "$PREFIX Holz")
|
||
stored_color "$PREFIX Holz hell" | grep -q "$BASE_ID" || fail "relative color does not reference $BASE_ID"
|
||
CYCLE=$(jq -cn --arg l "$PREFIX Holz" --arg b "{{color:$PREFIX Holz hell}}" \
|
||
'{items: [{label: $l, color: $b, relative: {lightness: 5}}]}')
|
||
[ "$(error_code "$(ability lit/divi-colors-set "$CYCLE")")" = "validation_failed" ] || fail "cycle not refused"
|
||
pass "relative color stored, cycle refused"
|
||
|
||
# 3. A main color changes the customizer option and reads back at once.
|
||
OLD_PRIMARY=$(wp eval 'echo et_get_option("accent_color", "#2ea3f2");')
|
||
NEW_PRIMARY="#123456"; [ "$OLD_PRIMARY" = "$NEW_PRIMARY" ] && NEW_PRIMARY="#654321"
|
||
DONE=$(confirm lit/divi-colors-set "$(jq -cn --arg c "$NEW_PRIMARY" '{items: [{name: "primary", color: $c}]}')")
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "main color: $DONE"
|
||
[ "$(wp eval 'echo et_get_option("accent_color");')" = "$NEW_PRIMARY" ] || fail "accent_color not written"
|
||
pass "main color written to the customizer option"
|
||
|
||
# 4. Deactivating a color is a status change, not a removal.
|
||
DONE=$(confirm lit/divi-colors-set "$(jq -cn --arg l "$PREFIX Holz hell" '{items: [{name: $l, status: "inactive"}]}')")
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "deactivate: $DONE"
|
||
wp eval "foreach (ET\Builder\Packages\GlobalData\GlobalData::get_global_colors() as \$c) { if ((\$c['label'] ?? '') === '$PREFIX Holz hell') { exit(\$c['status'] === 'inactive' ? 0 : 1); } } exit(1);" \
|
||
|| fail "color not inactive"
|
||
pass "color deactivated"
|
||
|
||
# 5. Variables: create a number variable, archive it (never deleted, V1).
|
||
VAR=$(jq -cn --arg l "$PREFIX Radius" '{items: [{type: "numbers", label: $l, value: "12px"}]}')
|
||
DONE=$(confirm lit/divi-variables-set "$VAR")
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "variable create: $DONE"
|
||
VARIABLES_SNAPSHOT=$(echo "$DONE" | jq -r '.snapshot_id')
|
||
[ "$(variable_field "$PREFIX Radius" value)" = "12px" ] || fail "variable not stored"
|
||
VAR_ID=$(variable_field "$PREFIX Radius" id)
|
||
case "$VAR_ID" in gvid-??????????) ;; *) fail "variable id format: $VAR_ID" ;; esac
|
||
pass "variable created ($VARIABLES_SNAPSHOT, $VAR_ID)"
|
||
|
||
# 6. A page uses the variable; detach replaces the reference by its value.
|
||
PAGE_ID=$(wp post create --post_type=page --post_status=draft --post_title="$PREFIX Seite" --post_content="$(wp eval "
|
||
\$ref = '\$variable(' . wp_json_encode(['type' => 'content', 'value' => ['name' => '$VAR_ID', 'settings' => new stdClass()]]) . ')\$';
|
||
\$text = ['content' => ['innerContent' => ['desktop' => ['value' => 'Hallo']]],
|
||
'module' => ['decoration' => ['spacing' => ['desktop' => ['value' => ['padding' => ['top' => \$ref]]]]]]];
|
||
echo serialize_block(['blockName' => 'divi/placeholder', 'attrs' => [], 'innerBlocks' => [
|
||
['blockName' => 'divi/section', 'attrs' => [], 'innerBlocks' => [
|
||
['blockName' => 'divi/row', 'attrs' => [], 'innerBlocks' => [
|
||
['blockName' => 'divi/column', 'attrs' => ['module' => ['decoration' => ['sizing' => ['desktop' => ['value' => ['flexType' => '24_24']]]]]], 'innerBlocks' => [
|
||
['blockName' => 'divi/text', 'attrs' => \$text, 'innerBlocks' => [], 'innerHTML' => '', 'innerContent' => []],
|
||
], 'innerHTML' => '', 'innerContent' => [null]],
|
||
], 'innerHTML' => '', 'innerContent' => [null]],
|
||
], 'innerHTML' => '', 'innerContent' => [null]],
|
||
], 'innerHTML' => '', 'innerContent' => [null]]);
|
||
")" --porcelain)
|
||
wp post get "$PAGE_ID" --field=post_content | grep -q "$VAR_ID" || fail "page does not reference the variable"
|
||
DETACH=$(jq -cn --arg v "$VAR_ID" --argjson p "$PAGE_ID" '{variable: $v, post_ids: [$p]}')
|
||
DRY=$(ability lit/divi-design-detach "$DETACH")
|
||
[ "$(error_code "$DRY")" = "confirm_required" ] || fail "detach dry run: $DRY"
|
||
DONE=$(ability lit/divi-design-detach "$(echo "$DETACH" | jq -c '. + {confirm: true}')")
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "detach: $DONE"
|
||
CONTENT=$(wp post get "$PAGE_ID" --field=post_content)
|
||
echo "$CONTENT" | grep -q "$VAR_ID" && fail "reference still in the page"
|
||
echo "$CONTENT" | grep -q '12px' || fail "value not written into the page"
|
||
pass "variable detached from the page (reference replaced by 12px)"
|
||
|
||
# 7. Archive the variable; it stays in the option with status archived.
|
||
DONE=$(confirm lit/divi-variables-set "$(jq -cn --arg l "$PREFIX Radius" '{items: [{name: $l, status: "archived"}]}')")
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "archive: $DONE"
|
||
[ "$(variable_field "$PREFIX Radius" status)" = "archived" ] || fail "variable not archived"
|
||
pass "variable archived, not removed"
|
||
|
||
# 8. Restore the variables snapshot taken before step 5: the variable created later stays, archived (V1).
|
||
LIST=$(ability lit/divi-design-snapshots '{"area": "variables"}')
|
||
echo "$LIST" | jq -e --arg s "$VARIABLES_SNAPSHOT" '[.snapshots[].id] | index($s) != null' >/dev/null || fail "snapshot not listed: $LIST"
|
||
DONE=$(confirm lit/divi-design-restore "$(jq -cn --arg s "$VARIABLES_SNAPSHOT" '{snapshot_id: $s}')")
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "variables restore: $DONE"
|
||
[ "$(variable_field "$PREFIX Radius" status)" = "archived" ] || fail "variable lost by the restore"
|
||
pass "variables restored, the later variable stays archived"
|
||
|
||
# 9. Restore the colors snapshot taken before step 1: the colors of this run are gone, the main color is back.
|
||
DONE=$(confirm lit/divi-design-restore "$(jq -cn --arg s "$COLORS_SNAPSHOT" '{snapshot_id: $s}')")
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "colors restore: $DONE"
|
||
[ -z "$(color_id "$PREFIX Holz")" ] || fail "restored colors still hold the new color"
|
||
[ "$(wp eval 'echo et_get_option("accent_color", "#2ea3f2");')" = "$OLD_PRIMARY" ] || fail "main color not restored"
|
||
pass "colors restored (new colors gone, main color back)"
|
||
|
||
finish
|
||
```
|
||
|
||
- [ ] **Step 2: Syntax prüfen**
|
||
|
||
Run: `bash -n tests/Integration/divi-colors-variables-smoke.sh && (command -v shellcheck >/dev/null && shellcheck -x tests/Integration/divi-colors-variables-smoke.sh || true)`
|
||
Expected: keine Syntaxfehler; shellcheck (falls installiert) ohne Fehler der Stufe error (Warnungen zu `$`-Escapes in `wp eval`-Strings sind erwartet).
|
||
|
||
- [ ] **Step 3: Formate-Nachtrag 5.15.1 für Farben und Variablen**
|
||
|
||
In `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` direkt nach dem Abschnitt „Nachtrag Divi 5.15.1 (Etappe 4a, 2026-10-16)“ einfügen:
|
||
|
||
```markdown
|
||
## Nachtrag Divi 5.15.1 – Farben und Variablen (Etappe 4c, 2026-10-19)
|
||
|
||
Geprüft gegen den installierten Quelltext `includes/builder-5/server/Packages/GlobalData/GlobalData.php` (5.15.1):
|
||
|
||
- **Farben unverändert:** `set_global_colors(array $data, $already_sanitized = false): void` mischt mit `true` (`array_merge`), ersetzt mit `false` und bereinigt dann selbst; die fünf Hauptfarben gehen über `et_update_option()` in `et_divi` und werden aus den Daten entfernt; danach `remove_static_resources('all', 'all', true)`. `sanitize_global_colors_data(array $data): array` ist öffentlich und verlangt, dass `usedInPosts` ein Array ist.
|
||
- **`get_customizer_colors()` hat einen `static`-Cache ohne Reset.** Nach einer Änderung der Hauptfarbe liefert `get_global_colors()` im selben Request noch den alten Wert (`$_cached_data` wird zurückgesetzt, der `static` nicht). LIT MCP liest die fünf Hauptfarben deshalb für das Zurücklesen aus `et_divi` (`GlobalColorsAdapter::read()`).
|
||
- **Variablen: neue Signatur und Prüfungen.** `set_global_variables(array $data, ?array $expected_variables = null): ?WP_Error` kehrt weiterhin still zurück, wenn `edit_theme_options` oder `variables_manager` fehlt; sonst läuft es unter einer MySQL-Sperre (`GET_LOCK`), prüft optional den erwarteten Bestand (`global_variables_stale`, 409), verlangt, dass **jede gespeicherte `gvid-`-ID im neuen Bestand vorkommt** (`validate_global_variable_ids()` → `global_variable_ids_missing`, 400) und prüft geänderte Einträge (`validate_global_variable_changes()`: `status` `active`|`archived`, aktive Variablen mit nicht leerem `value`). **Folge: Variablen lassen sich über Divis Funktionen nicht löschen, nur archivieren** (Ruling V1 des Plans 4c).
|
||
- **Neuer Typ `gradients`** (`GLOBAL_VARIABLE_TYPES = numbers, strings, images, links, fonts, gradients`); `strings` und `gradients` behalten ihren Wert zeichengetreu. `get_global_variables()` liefert zusätzlich die leere Gruppe `colors`, wandelt Objekt-Gruppen in Arrays und fügt `allowedActions` und die Customizer-Schriften hinzu (jede Gruppe als `stdClass`); beim Zurückschreiben bleiben nur Speicherformat-Felder.
|
||
- **Archivierte Variablen:** `_sort_global_variable_snapshot()` behandelt `order` archivierter Variablen als `''`; der Builder setzt beim Archivieren `order: null`, das Sanitize macht daraus `''`.
|
||
```
|
||
|
||
- [ ] **Step 4: Spec §7.2 nachführen**
|
||
|
||
In `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` die Zeilen mit `delete: true` und „Löschen nur unbenutzt …“ in §7.2 ersetzen:
|
||
|
||
Alt (Zeile `- \`items\`: { id? | name?, "type": … }`): `…, "status": "active"|"archived" }`, `delete: true` für echtes Löschen.`
|
||
Neu: `…, "status": "active"|"archived" }`. Echtes Löschen gibt es nicht: Divi 5.15.1 verweigert jeden Bestand, dem eine gespeicherte `gvid-`-ID fehlt (Plan 4c, Ruling V1).`
|
||
|
||
Alt (Zeile `- Löschen nur unbenutzt (G5; …) …`): `- Löschen nur unbenutzt (G5; Fundstellen auch in Presets und relativen Farben), sonst \`in_use\` mit Hinweis auf detach. „Archivieren“ geht immer (Divi-Verhalten).`
|
||
Neu: `- Archivieren geht immer (Divi-Verhalten); der Probelauf nennt Fundstellen in Seiten, Presets und Farben als Warnung, \`lit/divi-design-detach\` löst die Verweise vorher (§6.8). Ein Restore behält später angelegte Variablen archiviert.`
|
||
|
||
Run: `grep -n "delete: true\|Löschen nur unbenutzt" docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md`
|
||
Expected: keine Treffer in §7.2 (§6.4 „Nur unbenutzte Presets“ bleibt unberührt).
|
||
|
||
- [ ] **Step 5: Offene Punkte der Divi-Fähigkeiten-Notiz nachführen**
|
||
|
||
In `docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md` im Abschnitt „Offen“ den Punkt `DesignArea::feature() braucht … (Etappe 4c/4d).` ersetzen durch:
|
||
|
||
```markdown
|
||
- `DesignArea::feature()`: Farben (`color-write`) und Variablen (`variable-write`) haben seit Etappe 4c eigene Fähigkeiten; Theme-Optionen bekommen ihre mit 4d.
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add tests/Integration/divi-colors-variables-smoke.sh docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md \
|
||
docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md
|
||
git commit -m "test: Smoke für Farben und Variablen gegen Divi 5.15.1; docs: Formate-Nachtrag 4c, Variablen nur archivieren (Spec §7.2)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
### Task 12: Gesamtverifikation und Abschluss
|
||
|
||
**Files:**
|
||
- Modify: Memory `etappe-4-stand` (außerhalb des Repos, `/home/janniclas/.claude/projects/-home-janniclas-Projekte-WordPress-wp-content-plugins-WP-LIT-MCP/memory/etappe-4-stand.md`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: alles aus den Tasks 1–11.
|
||
- Produces: grüne Suite, grüne Smokes, Gesamtreview ohne offene wichtige Befunde, aktualisierter Stand.
|
||
|
||
- [ ] **Step 1: Suite und Lint**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: alle Tests grün (Zahl größer als 2877), phpcs ohne Befund.
|
||
|
||
- [ ] **Step 2: Divi-Prüfung und Referenz**
|
||
|
||
Run (DDEV, Divi 5.15.1): `cd /home/janniclas/Projekte/WordPress && ddev exec wp --user=admin lit-mcp divi-check`
|
||
Expected: Exit-Code 0, alle Fähigkeiten nutzbar, `color-write` und `variable-write` in der Matrix als „freigegeben“ bzw. „automatisch“. Meldet die Prüfung `signature`-Probleme für die neuen Symbole, `fingerprints.json` mit `--update-reference` neu erzeugen (Task 2 beschreibt das) und erneut prüfen.
|
||
|
||
- [ ] **Step 3: Smokes**
|
||
|
||
Run, jeweils einzeln (jeder muss mit `ALL CHECKS PASSED` enden):
|
||
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress
|
||
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-colors-variables-smoke.sh
|
||
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-smoke.sh
|
||
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-assign-smoke.sh
|
||
timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh
|
||
```
|
||
|
||
Erwartet: viermal `ALL CHECKS PASSED`. Schlägt ein Schritt des neuen Smokes fehl, zuerst die Antwort der Ability lesen (`fail` druckt sie), die Ursache im Code beheben (nicht das Skript lockern) und mit einem eigenen Commit nachziehen.
|
||
|
||
- [ ] **Step 4: Gesamtreview (zwei unabhängige Opus-Reviews parallel)**
|
||
|
||
Dispatch (zwei Subagents, Modell Opus, je mit dem Diff `git diff 63389f3..HEAD` und den Plan-Rulings V1–V10):
|
||
1. Fachreview: Korrektheit gegen Plan und Spec, Sicherheit (Werteprüfung, Rechte, `variables_manager`, Fundstellen-Rechte P8/P8a, Rollback, Snapshot-Größe), Verhalten der Randfälle (Hauptfarben, Zyklen, archivierte Variablen, Restore mit später angelegten Variablen).
|
||
2. `anthropic-skills:clean-code-review` über die neuen und geänderten Klassen (Quick-Modus), mit der Bitte, die vorgemerkten Punkte (`PresetStack`, `PageRewriter` aufteilen, gemeinsame Validierungshelfer der Abilities) nur zu erwähnen, nicht zu verlangen.
|
||
|
||
Wichtige und größere Befunde in einer Fix-Welle (Sonnet) beheben, danach Re-Review durch Opus; kleinere Befunde in die Ledger-Notiz der offenen Folgearbeit.
|
||
|
||
- [ ] **Step 5: Memory nachführen**
|
||
|
||
`etappe-4-stand.md` aktualisieren: 4c fertig (Commit-Hash, Testzahl, Datum), nächster Schritt „Plan 4d: Theme-Optionen und Schriften“ (`DesignArea::feature()` für `theme_options` braucht eine eigene Fähigkeit); aus 4c offen gelassene Punkte (aus dem Review) anhängen; Hinweis auf V1 (Variablen nur archivieren).
|
||
|
||
- [ ] **Step 6: Abschluss**
|
||
|
||
`git status` sauber; nichts pushen oder mergen ohne Auftrag. Dem Nutzer berichten: Testzahl, Smoke-Ergebnis, Befunde und Rulings (insbesondere V1), und fragen, ob der Branch gemergt/gepusht werden soll.
|
||
|
||
---
|
||
|
||
## Anhang: Abweichungen der Task-Autoren vom Schnittstellenvertrag (geprüft, im Plan berücksichtigt)
|
||
|
||
Die Autoren haben jeden Task in einer Kopie des Repos ausgeführt. Die folgenden Punkte weichen vom Vertrag ab oder verlangen Absprachen zwischen Tasks; wo der Vertrag oben schon angepasst ist, steht es dort. Verbindlich bleibt der Taskinhalt.
|
||
|
||
### Tasks 1-2
|
||
|
||
- **A1 Task 1 ohne eigenen Commit.** Global Constraints verlangen `composer test` grün nach jedem Task. Mit den zwei neuen Fällen allein schlägt `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` fehl (jede Fähigkeit braucht eine Manifest-Anforderung; die kommen erst mit den Adaptern) – per Probelauf bestätigt, sonst bricht nichts. Den Test aufzuweichen kommt nicht in Frage; darum committet Task 2 (Step 13) die Dateien beider Tasks zusammen. Alternative: Task 1 und 2 zu einem Task zusammenlegen.
|
||
- **A2 Adapter nicht `final`.** Der Schnittstellenvertrag schreibt „final class … implements DiviAdapter“. Das Repo hat kein bypass-finals; Mockery kann finale Klassen nicht ersetzen. `DesignRightsTest` (Task 2) und die Bereiche/Tools der Tasks 6/7/9 müssen die Adapter aber ersetzen (Divi fehlt in Unit-Tests). Die beiden Adapter folgen deshalb `DesignSystemReader`/`DesignSystemWriter` („Not final so … can be tested with a mock“). Namen und Signaturen bleiben wie im Vertrag.
|
||
- **A3 `save()` der Farben: „wirft, wenn Divi nichts speichert“ konkretisiert.** `set_global_colors()` ist `void` und meldet nichts. Der Adapter bereinigt deshalb in beiden Wegen vorher selbst mit `sanitize_global_colors_data()` und wirft, wenn dabei ein Eintrag wegfällt (leeres `color`, ID ohne `gcid-`); beim Ersetzen (`$replace = true`) bekommt `set_global_colors($clean, false)` die schon bereinigten Daten (Divi bereinigt erneut, idempotent). Was Divi danach still anders speichert, fängt erst `verify()` beim Zurücklesen (Task 6).
|
||
- **A4 Hauptfarben-Zuordnung doppelt.** `GlobalColorsAdapter` braucht die Abbildung ID → Option in `et_divi` schon in Task 2, `ColorBook::CUSTOMIZER_IDS` entsteht erst in Task 3. Der Adapter hält sie als `private const CUSTOMIZER`. Empfehlung für Task 3: `ColorBook::CUSTOMIZER_IDS` mit denselben Werten anlegen und in Task 3 einen Test ergänzen, der beide gleich hält – oder den Adapter in Task 3 auf `ColorBook::CUSTOMIZER_IDS` umstellen (ColorBook ist reines PHP, eine Konstante daraus berührt Divi nicht).
|
||
- **A5 Lese-Aufrufe nur mit der Schreib-Fähigkeit getaggt.** `get_global_colors`/`get_global_variables` stehen in den neuen Manifesten nur mit `color-write`/`variable-write` (Vorgabe des Manifests), nicht wie `GlobalPreset::get_data` im Writer zusätzlich mit `design-read`: das Designsystem-Tool liest weiter über `DesignSystemReader`, dessen Einträge `design-read`/`page-read` tragen. Ein Bruch dieser Aufrufe sperrt so Lesen (über den Reader) und Schreiben (über die Adapter) – wie gewünscht.
|
||
- **A6 `save()` prüft zusätzlich das Freigabe-Gate** (`ReleaseGate::assertFeature()` der eigenen Fähigkeit, wie `DesignSystemWriter::savePresets()`), obwohl `DesignChange::run()` das für `DesignArea::feature()` schon tut – doppelte Absicherung für Aufrufer außerhalb der Pipeline (Restore-Planer, Detach). Im Vertrag nicht genannt, ändert keine Signatur.
|
||
|
||
### Tasks 3-4
|
||
|
||
Keine Signatur des Schnittstellenvertrags ist geändert; die folgenden Punkte sind Auslegungen und Ergänzungen, die der Plan-Gesamtreview kennen muss. Der gesamte Code dieser zwei Tasks wurde in einer Kopie des Repos (Scratchpad `work/`, nur `src/` und `tests/` der neuen Dateien) geschrieben und ausgeführt: alle sechs Testklassen grün (121 Tests), `phpcs` ohne Meldung, die Gesamtsuite grün (2998 Tests, davon 2877 bestehende).
|
||
|
||
1. **`rev()` ist nicht sha1.** Der Vertrag nennt „sha1 über kanonisches JSON“; `PresetBook::rev()` (maßgebliches Vorbild laut Auftrag) benutzt `Rev::of()` = erste 12 Hex-Zeichen von sha256. Übernommen wurde das Verfahren von `PresetBook` (mit Präfix `colors`/`variables` + NUL, Schlüssel rekursiv sortiert, `JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES`). Die Signatur `rev(): string` bleibt; kein späterer Task hängt vom Hash-Verfahren ab.
|
||
2. **Farbverweis-Ziel ohne Änderung an `VariableRef`.** `VariableRef::nameOf()` liefert den Namen nur für `content`-Verweise; für `color`-Verweise gibt es keine öffentliche Lesefunktion (`settingsOf()` liefert nur die Einstellungen). `ColorBook::targetOf()` (privat, 6 Zeilen) nutzt `VariableRef::typeOf()` + `MARKER`/`END` + `json_decode`. Kleinste saubere Alternative, falls gewünscht: `VariableRef::colorNameOf(string): ?string` als Zwilling von `nameOf()` und `targetOf()` entfällt; für den Plan nicht nötig.
|
||
3. **`ColorDiff` kennt zusätzlich die Aktion `removed`** (Vertrag: `added|changed|deactivated|activated`). Ein Restore mit Ersetzen (V4: `set_global_colors($data, false)`) löscht Farben, die nach dem Snapshot entstanden; ohne `removed` fehlte diese Zeile im Probelauf. Task 9 (`ColorRestorePlanner`) kann stattdessen jüngere Farben als `inactive` behalten (wie V1b bei Variablen) – dann kommt `removed` nie vor, ist aber harmlos. Zusammenfassungstext dafür: „n gelöscht“. Außerdem: ein Wechsel nach `temporary` (oder andere Statuswerte) ist `changed` mit `status` in `fields`; `deactivated`/`activated` tragen `fields` nur für weitere Änderungen.
|
||
4. **`VariableBook::onlyHere()`** nennt nur aktive Einträge, die dem Snapshot fehlen (Vertrag: „Einträge, die `restoredFrom()` archiviert hätte“ – bereits archivierte archiviert `restoredFrom()` nicht neu, sie bekommen lediglich `order: ''`).
|
||
5. **`VariableDiff`** erzeugt für Einträge, die nachher fehlen, keine Zeile (V1: Variablen verschwinden nie); archivierte/aktivierte Zeilen ignorieren `order`.
|
||
6. **Beim Einlesen fallen Daten weg, die Divi selbst verwirft oder die das Format nicht kennt:** `VariableBook::fromDivi` ignoriert Einträge ohne `gvid-`-Präfix (Divis `sanitize_global_variables_data` verwirft sie beim Schreiben ebenfalls; das deckt zugleich die Customizer-Schriften ab), unbekannte Typen und fremde Eintragsschlüssel; `ColorBook::fromDivi` behält nur die sechs Schlüssel aus Formate §1 (Divis `sanitize_global_colors_data` würde beliebige Zusatzschlüssel mitschreiben – ein Snapshot gäbe sie nicht zurück). `ColorBook` setzt `lastUpdated` für jede Farbe mit `folder: customizer` auf `''`, nicht nur für die fünf Standard-IDs.
|
||
7. **Strenge der Werteprüfung (V5/V6) ist eine Auslegung:** `ColorValue::check()` trimmt nicht (Leerraum am Rand = ungültig; der Aufrufer trimmt vorher) und prüft Schlüsselwörter nicht gegen die CSS-Liste (jedes Wort aus 3–20 Buchstaben, Divi prüft ebenfalls nichts); `VariableValue` erlaubt bei Links auch `//host/pfad` (fällt unter „`/`-URL“). Divis `sanitize_text_field` kann bei `numbers`/`fonts`/`images` Zeichenfolgen wie `%ab` (Prozent plus zwei Hex-Zeichen) entfernen – das fängt das Zurücklesen (`verify`, Task 6/7), nicht die Vorabprüfung.
|
||
8. **Tiefengrenze:** „Tiefe > 10“ = mehr als 10 Verweise hintereinander (11 Verweise = 12 IDs in der Kette). Das entspricht `GlobalData::resolve_global_color_variable()` (`$depth >= 10` bricht ab; der 11. Verweis bliebe unaufgelöst). Der Vertrag lässt offen, ob Tiefe Verweise oder Farben zählt; die Tests (`testCycleStopsAtMoreThanTenReferences`) halten die Auslegung fest.
|
||
9. **`VariableBook::now()` ruft `ColorBook::now()` auf** (ein Zeitstempel-Format); Task 4 hängt dadurch von Task 3 ab. `newId()` ist in beiden Büchern getrennt (je 6 Zeilen), um keine dritte öffentliche Hilfsklasse außerhalb des Vertrags einzuführen.
|
||
10. **Test-Hilfen als öffentliche statische Methoden** (`ColorBookTest::stored()/ref()`, `VariableBookTest::stored()/fromGetter()/variable()`) – dasselbe Muster wie `PresetBookTest::stored()`; Tasks 6, 7 und 9 sollen sie wiederverwenden statt eigene Bestände zu bauen.
|
||
|
||
### Tasks 5-7
|
||
|
||
1. **Adapter nicht `final` (Task 2).** Der Schnittstellenvertrag nennt `final class GlobalColorsAdapter … implements DiviAdapter`. Die
|
||
Tests der Tasks 6/7 mocken `GlobalColorsAdapter` und `GlobalVariablesAdapter` mit Mockery (wie `DesignSystemWriter`, dessen Docblock
|
||
„Not final so abilities can be tested with a mock“ sagt); Mockery kann finale Klassen nicht mocken. Beide Adapter müssen darum
|
||
`class … implements DiviAdapter` sein (mit demselben Docblock-Satz). Alternative wäre ein Interface je Adapter – mehr Code ohne Nutzen.
|
||
2. **`PresetBook::withVariableResolved()` bekommt einen optionalen dritten Parameter** `bool $affixes = false`. V9 verlangt bei `strings`
|
||
`before . Wert . after` **aus den Einstellungen des jeweiligen Verweises** – das kann ein einziger `$value` nicht ausdrücken, und
|
||
`PresetBook` kennt den Typ der Variablen nicht. Vertragskompatibel (Default); Task 8 ruft `withVariableResolved($id, $value,
|
||
$variableType === 'strings')` auf.
|
||
3. **`ReferenceUsageQuery` nutzt nicht `NodeIndex`**, sondern läuft rekursiv über `Container::children()` des geparsten `Document`:
|
||
`NodeIndex::walk()` steigt nicht in opake Blöcke (globale Module) und Nicht-Divi-Blöcke ab, die Suche muss aber jeden Block sehen.
|
||
Zusätzlich fail closed: ein Treffer im Rohinhalt (`str_contains`, deckt Divi-4-Shortcodes wie `background_color="gcid-…"` ab) und
|
||
Blöcke mit unlesbaren Attributen zählen. Neu öffentlich: `ESCAPED_HYPHEN_REGEX` (zweites `REGEXP` für `gcid\u002d…`, das `LIKE` nicht
|
||
findet) und `mentions()` (von `PresetBook::referencing()` mitbenutzt); der Konstruktor nimmt optional `Parser $parser = new Parser()`.
|
||
4. **Konstruktoren der Abilities (für Task 10):** `DiviColorsSetAbility(DesignSystemWriter $writer, GlobalColorsAdapter $colors,
|
||
DesignChange $change, GlobalUsages $usages, ?Closure $random = null)` und `DiviVariablesSetAbility(DesignSystemWriter $writer,
|
||
GlobalVariablesAdapter $variables, DesignChange $change, GlobalUsages $usages, ?Closure $random = null)`. Der `DesignSystemWriter` wird
|
||
für `DesignRights::colors()/variables()` und für `presets()` (Warnungen) gebraucht.
|
||
5. **`VariableArea::write()` liest `$expected` selbst**, unmittelbar vor `save()` (`DesignArea::write()` bekommt den Planungsbestand nicht).
|
||
Gleichwertig zu V7, weil `DesignChange` unter der Sperre gerade erst die `design_rev` dieses Bestands verglichen hat; Divis
|
||
Stale-Prüfung fängt nur noch Schreibzugriffe am Plugin vorbei.
|
||
6. **`VariableArea::restore()` wendet `restoredFrom()` selbst an** (idempotent, wenn der Restore-Planer aus Task 9 es schon getan hat). Ohne
|
||
das würde der Rollback der Pipeline nach einem durchgegangenen Schreiben mit anschließend fehlgeschlagenem `verify()` an Divis
|
||
`global_variable_ids_missing` scheitern (die neue ID wäre gespeichert, der Snapshot kennt sie nicht) → `partial` statt `rolled_back`.
|
||
7. **Zusätzliche Regeln, die V3/V5/Spec nicht ausdrücklich nennen:** Hauptfarben bekommen keinen `{{color:…}}`-Verweis (Divi speichert sie
|
||
als festen Wert in `et_divi`, ein `$variable(…)$` bräche den Customizer); `{{color:…}}` verlangt `relative` (V5 „**mit** relative“);
|
||
Namen neuer/umbenannter Farben bzw. Variablen müssen frei sein (nicht Name, ID oder Alias eines anderen Eintrags, sonst würden
|
||
`{{color:…}}`/`{{var:…}}` mehrdeutig – analog A5 bei Presets); Ordner `customizer` ist für neue Farben gesperrt.
|
||
8. **Risiko `VariableArea::verify()`:** Es vergleicht wie gefordert nur IDs, Status und „Wert nicht leer“. Tut Divi bei einer reinen
|
||
Wertänderung still nichts (fehlendes `variables_manager`), besteht `verify()`, und `differences()` meldet „bereinigt“. Abgefangen
|
||
wird das vorher durch `DesignRights::variables()` (V7); wer es enger will, vergleicht in `verify()` zusätzlich die Werte der
|
||
geänderten Einträge.
|
||
9. **Annahmen über die parallel geplanten Klassen, auf denen die Tests ruhen:** `ColorBook::fromDivi($book->data())` und
|
||
`VariableBook::fromDivi($book->data())` sind idempotent; `ColorDiff`/`VariableDiff` liefern in `name` das Label; `VariableBook::fromDivi()`
|
||
behält die Gruppe `gradients`; `$book->restoredFrom($book)` ergibt denselben Bestand; `ColorValue::check('kein Wert!')`,
|
||
`ColorValue::check('$variable(…)$')`, `ColorValue::relative(['farbe' => 10])` und `VariableValue::check('numbers', '1px;}')` melden
|
||
Fehler, `ColorValue::check('white')` nicht; `nextOrder('strings')` ist bei vorhandenem `order` `'1'` und archivierten `''` gleich `'2'`;
|
||
`ColorBook::cycle()` liefert eine Kette, die `gcid-sand000002` enthält. Weicht ein Task-3/4-Plan davon ab, sind nur Testerwartungen
|
||
anzupassen, nicht der Ablauf. (Geprüft: Task 5 gegen den echten Code; Tasks 6/7 mit einfachen Ersatzklassen für Tasks 1–4 nach dem
|
||
Vertrag in einer Kopie außerhalb des Repos – alle RED-/GREEN-Schritte verhalten sich wie beschrieben, die 312 Design-Tests bleiben grün,
|
||
phpcs ist sauber.)
|
||
10. **Neue IDs zwischen Probelauf und `confirm`:** Wie bei `lit/divi-presets-set` (`PresetBook::newId()`) wird beim `confirm` neu geplant,
|
||
eine neue Farbe/Variable bekommt also eine andere zufällige ID als im Probelauf-`diff`. Die `design_rev` bezieht sich auf den Bestand,
|
||
nicht auf den Plan, darum stört das die Bestätigung nicht; die Tests prüfen IDs deshalb nur per Muster.
|
||
|
||
### Tasks 8-9
|
||
|
||
- **Verdrahtung vorgezogen (Task 10):** Beide Tools bekommen neue Konstruktor-Signaturen; `DiviIntegration::abilities()` wird in `DiviIntegrationTest`, `DiviToolFeaturesTest`, `IntegrationRegistryTest` u. a. gebaut. Damit `composer test` nach Task 8 und 9 grün ist, ändern 8c/9a/9c die zwei Konstruktoraufrufe in `DiviIntegration` (Adapter inline per `new`) und die Zeilen von `lit/divi-design-detach`/`lit/divi-design-restore` in `DiviToolFeaturesTest`. Task 10 darf das nicht doppelt tun.
|
||
- **Adapter müssen mockbar sein:** Der Vertrag nennt `GlobalColorsAdapter` „final class“. Die Ability- und Planer-Tests mocken beide Adapter mit Mockery (wie `DesignSystemWriter`, „Not final so abilities can be tested with a mock“); Mockery kann finale Klassen nicht mocken. Annahme: beide Adapter sind nicht final und haben einen Konstruktor ohne Pflichtargumente.
|
||
- **`PresetBook::withVariableResolved(string $id, string $value)` kennt den Variablentyp nicht:** Verweise auf eine `strings`-Variable mit `before`/`after` *in Presets* verlören diese Texte, während Seiten sie behalten (V9). Vorschlag für Task 5: intern `VariableDetachRewrite::replacement($ref, $value, $type)` nutzen bzw. die Signatur um `string $variableType` erweitern. Task 8 ruft die Methode wie im Vertrag auf.
|
||
- **`ColorDiff` kennt kein `removed`:** Ein Farben-Restore ersetzt über `set_global_colors($data, false)` alle Farben (V4) und entfernt damit neuere Farben. `ColorRestorePlanner` ergänzt die `removed`-Zeilen selbst und filtert Zeilen von `between()` zu IDs, die der Snapshot nicht kennt. Sauberer wäre `removed` in `ColorDiff`/`summary()` (Task 3).
|
||
- **Eingebettete Verweise:** `TreeRewrite` ruft `notes()` nur für geänderte Blöcke. Ein Modul (oder eine Seite), das die Variable *nur* eingebettet nennt, bekommt keinen Hinweis und steht nicht in `pages`; `ReferenceUsageQuery` zählt die Seite aber weiter als Fundstelle (blockiert als Live-Seite ohne `edit_post`, P8a), und der Fertig-Hinweis kann erscheinen, obwohl eingebettete Verweise bleiben. Die Tool-Beschreibung sagt, dass sie stehen bleiben.
|
||
- **`VariableWrite` im Detach:** Der Detach schreibt keine Variablen, liest sie aber über `GlobalVariablesAdapter` und prüft `canManage()`. Ob dafür `VariableWrite` nötig ist, hängt am Manifest aus Task 2 (Fähigkeiten von `get_global_variables`/`et_pb_is_allowed`); gewählt ist der Zweig `variable` = bisherige Liste + `VariableWrite`.
|
||
- **Rechte bei ungültiger `snapshot_id`:** Ohne gültigen Bereich prüft die Restore-Ability die Rechte aller Planer (fail closed) – ein Konto ohne `variables_manager` bekommt dann `forbidden` statt `validation_failed`.
|
||
- **Variablen ohne Textwert:** Ist `value` einer Variablen kein String (z. B. `gradients`, falls Divi den Typ als Objekt speichert), antwortet der Detach mit `unsupported`; V6 sagt nur „unverändert durchgereicht“.
|
||
|