# 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', , )`, 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/` (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/.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 "" -m "Co-Authored-By: Claude "` 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:}}` **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', , )` 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":"",…}})$` 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> get_global_colors() mit den Hauptfarben aus et_divi (V3) */ public function read(): array; /** @param array> $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>> Speicherform, Typ => ID => Eintrag (V7) */ public function read(): array; /** @param array>> $data @param array>> $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 $relative @return array|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}> */ 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 IDs von Farben, deren color ein Verweis auf $id ist public function cycle(): ?array; // list Kette eines Zyklus oder einer Tiefe > 10, sonst null public function changedSince(self $before): array; // array 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}> 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>> 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 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}> 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 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 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 /** @param array $current @param array $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 '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-, indirect lit/): $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 ')`, 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: ')`; `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 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 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 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> 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> $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 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>> */ 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>> $data The whole new inventory (stored form). * @param array>> $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 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 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 $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": ""`, `"…GlobalData::set_global_colors": ""`, `"…GlobalData::set_global_variables": ""`; 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 " ``` --- ### 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`, `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 */ 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 */ 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 }} 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:}} ' . '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 $relative * @return array|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 " ``` #### 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 > 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 $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 {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}> $data */ private function __construct(private array $data) { } /** @param array|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}> */ 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}|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 $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 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|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}> */ 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 $entry * @return array{color: string, folder: string, label: string, lastUpdated: string, status: string, * usedInPosts: list} */ 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 " ``` #### 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 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 }> * 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> $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 " ``` #### 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 */ 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" & '], '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 */ 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 "]+|\/[^\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 " ``` #### 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 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>> 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 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 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>> $data */ private function __construct(private array $data) { } /** @param array|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>> */ 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|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 $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 */ 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 $entry * @return array */ 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>> $data * @return array>> */ 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 " ``` #### 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 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 }> * 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> $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 " ``` #### 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` 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` (`scope` = Modul- bzw. Gruppenname wie in `PresetDiff`). - `PresetBook::withVariableResolved(string $id, string $value, bool $affixes = false): self` – ganze Verweise `$variable({"type":"content","value":{"name":"",…}})$` 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 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('')); self::assertTrue($query->matches('')); self::assertFalse($query->matches('')); self::assertFalse($query->matches('')); self::assertFalse($query->matches('

Keine Verweise

')); } public function testFindsAnIdWrittenWithJsonEscapesAlsoInsideGlobalModules(): void { $query = new ReferenceUsageQuery(['gcid-holz000001']); self::assertTrue($query->matches('')); self::assertTrue($query->matches('' . '')); self::assertFalse($query->matches('')); } public function testUnreadableMarkupAndDivi4ContentCount(): void { $query = new ReferenceUsageQuery(['gcid-holz000001']); self::assertTrue($query->matches(''), 'never closed'); self::assertTrue($query->matches(''), '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 ",…}})$ 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 */ private array $ids; /** * @param list $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 $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 " ``` #### 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 */ 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 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 $settings * @return array */ 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 " ``` - [ ] **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()` ü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 > */ 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> $stored * @param list, 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> $stored * @param array $fields * @return array> */ private static function changed(array $stored, string $id, array $fields): array { $stored[$id] = $fields + $stored[$id]; return $stored; } /** @return array */ 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 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 $raw * @return array 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 " ``` #### 6b – `lit/divi-colors-set`: Hülle, Pipeline, feste Farbwerte, Hauptfarben - [ ] **Step 6: Failing tests schreiben** `tests/Unit/Integrations/Divi/Design/DiviColorsSetAbilityTest.php`: ```php > What the adapter stores (ColorAreaTest::adapter()). */ private array $stored = []; /** @var list, 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 $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('') && ! $query->matches(''))) ->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 '', '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:}} 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 $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 $item * @param array $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 $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 $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 " ``` #### 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 '', '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:}} 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 $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 $item * @param array $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 $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 $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> $diff * @return list */ private function warnings(ColorBook $book, array $diff, ?UsageReport $report): array { $presets = null; $warnings = []; $deactivates = false; foreach ($diff as $row) { if ($row['action'] === 'added') { continue; } $deactivated = $row['action'] === 'deactivated'; $deactivates = $deactivates || $deactivated; $presets ??= PresetBook::fromDivi($this->writer->presets()); $users = array_column($presets->referencing((string) $row['id']), 'name'); if ($users !== []) { $warnings[] = sprintf( $deactivated ? 'Farbe „%s“ wird deaktiviert, aber noch von Presets verwendet: %s.' : 'Die Änderung an Farbe „%s“ wirkt auch auf die Presets %s.', $row['name'], self::names($users) ); } $derived = array_map($book->label(...), $book->referencing((string) $row['id'])); if ($derived !== []) { $warnings[] = sprintf( $deactivated ? 'Farbe „%1$s“ wird deaktiviert, aber die relativen Farben %2$s bauen auf ihr auf.' : 'Die relativen Farben %2$s bauen auf Farbe „%1$s“ auf und ändern sich mit.', $row['name'], self::names($derived) ); } } if ($deactivates && $report !== null && $report->exists()) { $warnings[] = self::IN_USE; } return $warnings; } /** @param list $names */ private static function names(array $names): string { return implode(', ', array_slice($names, 0, self::LISTED)) . (count($names) > self::LISTED ? ', …' : ''); } } ``` - [ ] **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 " ``` - [ ] **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(), )` (`$mayRemove` ohne Bedeutung: es wird nie entfernt, V1); `restore($raw)` = `save(->restoredFrom(<$raw>)->data(), )` – 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 >> */ 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>> $stored * @param list, 1: array}> $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 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 $raw * @return array "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 " ``` #### 7b – `lit/divi-variables-set`: Hülle, Pipeline, Typen, Werte, Archivieren - [ ] **Step 6: Failing tests schreiben** `tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php`: ```php >> What VariableAreaTest::adapter() stores. */ private array $stored = []; /** @var list, 1: array}> */ 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 $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('') && ! $query->matches(''))) ->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 '', '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 $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 $item * @param array $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 $item * @param array $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 $item * @param array $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 " ``` #### 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> $diff * @return list */ 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 $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 " ``` - [ ] **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 $ids)`; `PresetBook::referencing(string $id): list`, `->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":"",…}})$` 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 |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 |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 $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> $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` = ` „“`, `nestedIn` = `anderen Presets`. Der Rest der Datei (Kopf bis `execute()`, `firstPageError()`, `invalid()`) bleibt. Ganze Datei nach dem Umbau: ```php 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 $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|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>, 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): (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 $input * @return array|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 $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 $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 " ``` #### 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 ['value' => $value]]; } /** * @param list}> $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('', $name, Json::encode($attrs)); } return NodeIndex::build((new Parser())->parse( '' . $inner . '' )); } 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 ",…}})$ 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 " ``` #### 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>> 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 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( '' ) && ! $query->matches(''); } /** 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 $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 $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 " ``` 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 $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 */ public function features(): array; /** * @param array $current The area's inventory now (DesignArea::raw(), read by DesignChange). * @param array $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 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 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 $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 $input * @return list 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 $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 " ``` #### 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 $color, 'folder' => '', 'label' => $label, 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'status' => 'active', 'usedInPosts' => []]; } /** @return array> 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> 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 $id, 'label' => $label, 'value' => $value, 'order' => '1', 'status' => 'active', 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => 'numbers']; } /** @return array>> 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>> 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 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 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 " ``` #### 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> $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>> $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 $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 " ``` ### 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/`, nicht `lit-`), 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 " ``` ### 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 …" 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 : 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 " ``` ### 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“.