Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5395 lines
226 KiB
Markdown
5395 lines
226 KiB
Markdown
# Etappe 4a – Designsystem: Änderungs-Pipeline, Snapshots und Presets – 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 liest Presets mit ihren Einstellungen (`lit/divi-preset-get`), legt Modul- und Gruppen-Presets an und ändert sie (`lit/divi-presets-set`, auch aus einem Modul einer Seite und als Standard-Preset), löscht unbenutzte Presets (`lit/divi-presets-delete`) und stellt den Preset-Bestand aus Snapshots wieder her (`lit/divi-design-snapshots`, `lit/divi-design-restore`) – jede Änderung mit Probelauf, `confirm`, Snapshot, Protokoll und über Divis eigenen Speicherweg.
|
||
|
||
**Architecture:** Reines PHP in `Divi/Design/` beschreibt Bestand (`PresetBook`), Bereich (`PresetScope`), Unterschied (`PresetDiff`) und den Aufbau eines Presets (`PresetComposer`, nutzt den vorhandenen `Composer::update()` an einem gedachten Modulblock). Ein neuer Adapter `DesignSystemWriter` ist die einzige Divi-Berührung beim Schreiben; er wiederholt die Schrittfolge von Divis `GlobalPresetController::sync` (Mirror mit Fingerabdruck). Die gemeinsame `DesignChange`-Pipeline (Rechte geprüft vom Tool → Probelauf mit `diff`/`affected`/`design_rev` → `confirm` → `design_rev`-Vergleich → Snapshot (`SnapshotStore`) → Schreiben → Zurücklesen → Protokoll, bei Fehler Rollback) ist für alle Bereiche gebaut und wird hier mit dem Bereich `presets` (`PresetArea`) genutzt. Fundstellen liefert `GlobalUsages` über eine austauschbare Suche (`UsageQuery`): die bisherige Bibliothekssuche wird `LibraryUsageQuery`, neu kommt `PresetUsageQuery`.
|
||
|
||
**Tech Stack:** PHP ≥ 8.1, WordPress 7.x (Abilities API, Optionen, `$wpdb`), Divi 5.15.1 (`GlobalPreset`, `Conversion`, `UserRole`, `ET_Core_PageResource`), `wordpress/mcp-adapter` 0.5, 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` (§3–§6.4, §10–§14; Annahmen A1–A11 freigegeben), Entscheidungen `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` (G1–G12, verbindlich), Datenformate `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` (Stand 5.2.1, Task 1 führt sie auf 5.15.1 nach), Fixtures `tests/Fixtures/divi/stage4/`, Seed `tests/Integration/divi-seed-stage4-design.php`.
|
||
|
||
**Voraussetzung:** Branch `etappe-4` mit Stand d391cb5 (Spec committet). Divi 5.15.1 ist installiert; die offene Freigabe von 5.15.1 (`DiviReleases::TESTED`, `fingerprints.json`, `vb-sync-flags.txt` im Arbeitsbaum) ist **vor Task 1 vom Benutzer committet**. 2345 Unit-Tests grün.
|
||
|
||
**Teil von Etappe 4:** 4a (dieser Plan) → 4b Zuweisen an Seiten (Stapel, Gruppen, Überdeckungen, Massenzuweisung, detach) → 4c Farben und Variablen → 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 den Preset-Bestand am Ende aus dem eigenen Snapshot wieder her.
|
||
- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.x); Divi 5.15.1. Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, jede Datei mit `defined('ABSPATH') || exit;`, höchstens 120 Zeichen pro Zeile. Nach jedem Task: `composer test` und `composer lint` sauber.
|
||
- **Divi-Grenze (Divi-Absicherung §3):** Divi-Aufrufe (`GlobalPreset`, `Conversion`, `UserRole`, `et_*`, `ET_*`) nur in Adaptern, die in `DiviAdapters::CLASSES` stehen und jeden Aufruf im Manifest führen; nachgebaute Divi-Abläufe als `Mirror` mit Fingerabdruck in `src/Integrations/Divi/Release/fingerprints.json` und Referenz in `tests/Fixtures/divi/reference/<id>.txt`. `DiviBoundaryTest` bleibt grün. Jede schreibende Adapter-Methode ruft zuerst `ReleaseGate::assertWritable()`.
|
||
- `src/Integrations/Divi/BlockTree/*`, `src/Integrations/Divi/Compose/*` und die neuen reinen Klassen `PresetScope`, `PresetBook`, `PresetDiff`, `PresetComposer`, `PresetUsageMatcher` bleiben reines PHP ohne WordPress-Aufrufe.
|
||
- **Eine Engstelle für Client-Werte (D7):** Preset-Attribute aus `set`/`attrs` laufen ausschließlich durch `Composer::update()` (ShortformWriter/AttrWriter/ValueCleaner); Werte aus `from_node` sind gespeicherte Werte einer Seite und laufen durch `StoredNodeGuard` (Sperren S7/S26 für Benutzer ohne `unfiltered_html`). Vor dem Speichern bereinigt zusätzlich Divis `GlobalPreset::prepare_data()`.
|
||
- **Site-weite Änderungen (G2, Spec §4):** Ohne `confirm: true` nie schreiben; Probelauf = `Errors::confirmRequired()` mit `diff`, `affected`, `affected_count`, `design_rev`. Mit `confirm` Pflicht-Eingabe `design_rev`; Abweichung → `Errors::revMismatch()` mit eigener Meldung. Vor jedem Schreiben ein Snapshot; ohne Snapshot kein Schreiben.
|
||
- **Rechte (Spec §10):** Lesen = `edit_posts`. Presets schreiben, löschen, wiederherstellen = `edit_theme_options` + Divi `UserRole::can_current_user_use_visual_builder()` (über `DesignSystemWriter::canUseVisualBuilder()`). Snapshots auflisten = `edit_theme_options`.
|
||
- **Exposition:** Alle neuen Tools sind indirekt (über `mcp-adapter-execute-ability`), Gruppe „Divi“ (`divi`). `docs/guides/overview.md` „Direkt sichtbar:“ bleibt unverändert. Texte nennen indirekte Tools `lit/<name>` (ToolNamingTest).
|
||
- **Sprache:** Meldungen, Hinweise, Guides auf Deutsch mit echten Umlauten; neutral „KI-Assistent“, „MCP-Client“.
|
||
- Tests: Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); 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`. Integration: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh`.
|
||
- Commits: `git commit -m "<Nachricht>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"` (im Folgenden „Commit wie in den Global Constraints“). Nur die Dateien des Tasks stagen.
|
||
|
||
## Review Focus
|
||
|
||
1. **Preset mit Skript- oder HTML-Attributen von einem Redakteur ohne `unfiltered_html`** (über `set`/`attrs` oder `from_node` einer Admin-Seite): abgelehnt, nichts gespeichert → `PresetComposerTest::testEditorCannotCarryBuilderOnlyValuesFromANode`, `PresetsSetAbilityTest::testEditorWithoutUnfilteredHtmlIsRefusedForRawCss` (Tasks 9, 11).
|
||
2. **Inhalte im Preset** (Text, URL, Bild-ID): abgelehnt, weil Divis Preset-Mapping sie nicht als Preset-Attribut führt → `PresetComposerTest::testContentPathsAreRefused` (Task 9).
|
||
3. **Parallele Änderung** zwischen Probelauf und `confirm` (anderes Fenster im Builder): `rev_mismatch`, nichts geschrieben, kein Snapshot → `DesignChangeTest::testStaleDesignRevWritesNothing` (Task 8).
|
||
4. **Schreiben scheitert oder liest anders zurück**: Snapshot wird zurückgeschrieben, `write_failed`; scheitert auch das, `partial_write` mit `snapshot_id` → `DesignChangeTest::testFailedWriteRestoresTheSnapshot`, `::testFailedRollbackIsPartial` (Task 8).
|
||
5. **Löschen eines benutzten Presets** (Seite, Bibliothek, Theme Builder, als verschachteltes Gruppen-Preset): `in_use` mit Fundstellen → `PresetsDeleteAbilityTest::testPresetInUseIsRefused`, `::testNestedUseInAnotherPresetCounts` (Task 12).
|
||
6. **Rechte**: Autor ohne `edit_theme_options`, Administrator mit Divi-Rolle ohne Visual Builder → `forbidden` → `PresetsSetAbilityTest::testNeedsEditThemeOptionsAndTheVisualBuilder` (Task 11).
|
||
7. **Divi nicht freigegeben**: Schreiben gesperrt (`DiviLocked`) vor jedem Snapshot → `DesignSystemWriterTest::testWritesNeedAReleasedDivi` (Task 5), `DesignChangeTest::testLockedDiviWritesNoSnapshot` (Task 8).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
src/
|
||
Uninstaller.php (geändert) Snapshot-Optionen
|
||
Integrations/Divi/
|
||
DiviIntegration.php (geändert) neue Tools, Writer, Snapshots, Usages
|
||
Release/DiviAdapters.php (geändert) DesignSystemWriter
|
||
Release/fingerprints.json (geändert) preset-sync
|
||
Library/DiviRoles.php (unverändert)
|
||
Library/GlobalUsages.php (geändert) report()/rows() über UsageQuery
|
||
Library/UsageQuery.php (neu) Suchstrategie: Kandidaten-SQL + Treffer
|
||
Library/LibraryUsageQuery.php (neu) bisherige Bibliothekssuche
|
||
Design/
|
||
PresetScope.php (neu) module|group + voller Name
|
||
PresetBook.php (neu) Preset-Bestand im Speicherformat
|
||
PresetDiff.php (neu) Unterschied zweier Bestände
|
||
PresetComposer.php (neu) Preset-Eintrag aus set/attrs/from_node
|
||
PresetUsageMatcher.php (neu) Inhalt verwendet Preset? (rein)
|
||
PresetUsageQuery.php (neu) UsageQuery für ein Preset
|
||
DesignSystemWriter.php (neu) Adapter: Presets schreiben, Mapping, Rechte
|
||
SnapshotStore.php (neu) Snapshots je Bereich
|
||
SnapshotTooLarge.php (neu) Ausnahme
|
||
DesignArea.php (neu) Schnittstelle eines Bereichs
|
||
PresetArea.php (neu) Bereich presets
|
||
DesignChange.php (neu) Pipeline
|
||
DesignPlan.php (neu) Ergebnis der Planung (neuer Bestand, diff, affected)
|
||
DiviPresetGetAbility.php (neu) lit/divi-preset-get
|
||
DiviPresetsSetAbility.php (neu) lit/divi-presets-set
|
||
DiviPresetsDeleteAbility.php (neu) lit/divi-presets-delete
|
||
DiviDesignSnapshotsAbility.php (neu) lit/divi-design-snapshots
|
||
DiviDesignRestoreAbility.php (neu) lit/divi-design-restore
|
||
tests/
|
||
Fixtures/divi/reference/preset-sync.txt (neu) Referenz des Mirrors
|
||
Unit/Integrations/Divi/Design/…Test.php (neu) je Klasse
|
||
Unit/Integrations/Divi/Library/GlobalUsagesTest.php (geändert) Suche über UsageQuery
|
||
Unit/UninstallerTest.php (geändert)
|
||
Integration/divi-presets-smoke.sh (neu) Ende-zu-Ende gegen echtes Divi
|
||
docs/
|
||
guides/divi-design-system.md (geändert) Abschnitt „Presets anlegen und ändern“
|
||
superpowers/notes/2026-09-28-etappe-4-divi-formate.md (geändert) Stand 5.15.1
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Datenformate und Signaturen gegen Divi 5.15.1 prüfen (Presets, Rechte, Caches)
|
||
|
||
Reiner Recherche-Task; Ergebnis ist die nachgeführte Formate-Notiz. Kein Produktionscode.
|
||
|
||
**Files:**
|
||
- Modify: `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: —
|
||
- Produces: verifizierte Fakten, auf die Tasks 2–14 bauen (Abschnitt „Nachtrag 5.15.1“).
|
||
|
||
- [ ] **Step 1: Divi-Version und Preset-Funktionen feststellen**
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress/wp-content/themes/Divi
|
||
grep -m1 "Version:" style.css
|
||
cd includes/builder-5/server
|
||
grep -n "public static function \(option_name\|get_data\|prepare_data\|validate_preset_count\|save_data\|get_deleted_preset_ids\|remove_preset_from_legacy_option\)" Packages/GlobalData/GlobalPreset.php
|
||
grep -n "public static function \(get_preset_attrs\|get_preset_attrs_mapping\)" Packages/Conversion/Conversion.php
|
||
grep -n "public static function can_current_user_use_visual_builder" Framework/UserRole/UserRole.php
|
||
grep -n "public static function sync\|sync_permission\|edit_theme_options" Packages/GlobalData/GlobalPresetController.php
|
||
```
|
||
Expected (Stand 5.15.1, beim Schreiben dieses Plans geprüft): `Version: 5.15.1`; `prepare_data( array $schema_items ): array`, `validate_preset_count( array $current_presets, array $presets_to_sync, string $action_type ): ?WP_Error`, `save_data( array $data, bool $skip_external_cache = false ): array`, `get_deleted_preset_ids( array $current_presets, array $incoming_presets, string $preset_type ): array`, `remove_preset_from_legacy_option( string $preset_id, string $module_name ): bool`; `sync_permission` = `UserRole::can_current_user_use_visual_builder() && current_user_can( 'edit_theme_options' )`. Weicht etwas ab, die Abweichung in Step 4 notieren und die betroffenen Tasks vor der Umsetzung anpassen.
|
||
|
||
- [ ] **Step 2: Ablauf von `GlobalPresetController::sync` lesen**
|
||
|
||
Run: `sed -n '/public static function sync/,/^\t}/p' Packages/GlobalData/GlobalPresetController.php`
|
||
Festhalten (für den Mirror in Task 5): ohne Chunks `prepare_data($presets)` → `get_data()` → `validate_preset_count($current, $prepared, $actionType)` → bei `DELETE_MODULE_PRESET`/`DELETE_OPTION_GROUP_PRESET` `get_deleted_preset_ids(…, 'module'|'group')` und je Eintrag `remove_preset_from_legacy_option($id, $moduleName)` → `save_data($prepared)`.
|
||
|
||
- [ ] **Step 3: Gespeichertes Format am lebenden Bestand prüfen**
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress
|
||
ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-seed-stage4-design.php
|
||
ddev wp eval 'echo wp_json_encode(ET\Builder\Packages\GlobalData\GlobalPreset::get_data(), JSON_PRETTY_PRINT);' | head -80
|
||
ddev wp eval '$m = ET\Builder\Packages\Conversion\Conversion::get_preset_attrs_mapping("divi/button"); echo wp_json_encode(array_slice($m, 0, 5, true), JSON_PRETTY_PRINT);'
|
||
```
|
||
Expected: Speicherformat wie Formate §3 (`module`/`group` → Name → `{default, items: {id: item}}`, Einträge mit `type`, `id`, `name`, `moduleName`, `version`, `created`/`updated` in Millisekunden, `attrs`, `styleAttrs`, `renderAttrs`). Das Mapping ist ein Array Attributname → Einträge mit `attrName`, `preset` (Liste aus `style`/`html`/`script`/`content`). Abweichungen notieren.
|
||
|
||
- [ ] **Step 4: Notiz nachführen**
|
||
|
||
In `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` direkt nach der Einleitung einfügen:
|
||
|
||
```markdown
|
||
## Nachtrag Divi 5.15.1 (Etappe 4a, 2026-10-16)
|
||
|
||
Geprüft gegen Divi 5.15.1 (installiert, freigegeben):
|
||
- `GlobalPreset::save_data()` hat den zweiten Parameter `$skip_external_cache = false`; das Plugin übergibt ihn nicht.
|
||
- `GlobalPresetController::sync_permission()` verlangt jetzt **zusätzlich `edit_theme_options`** (5.2.1: nur Visual-Builder-Recht). Die Rechte in Spec §10 entsprechen damit Divi.
|
||
- Der Sync nimmt Chunks an (`requestId`, `chunkIndex`, `totalChunks`, Transient `et_global_preset_chunks_<user>_<id>`); das Plugin schickt nie Chunks.
|
||
- Schrittfolge des Sync (ohne Chunks) unverändert: `prepare_data` → `get_data` → `validate_preset_count` → bei Löschaktionen `get_deleted_preset_ids` + `remove_preset_from_legacy_option` → `save_data`. Das Plugin wiederholt sie in `DesignSystemWriter::savePresets()` (Mirror `preset-sync`).
|
||
- <weitere Abweichungen aus Step 1–3 oder „keine“>
|
||
```
|
||
Die letzte Zeile mit dem Ergebnis von Step 1–3 füllen (bei keiner Abweichung „Keine weiteren Abweichungen zu §3.“).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md
|
||
git commit -m "docs: Divi-Formate der Presets gegen 5.15.1 geprüft" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `PresetScope` und `PresetBook` – Preset-Bestand im Speicherformat
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/PresetScope.php`
|
||
- Create: `src/Integrations/Divi/Design/PresetBook.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/PresetScopeTest.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/PresetBookTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LIT\MCP\Abilities\Rev::of(string): string`.
|
||
- Produces:
|
||
- `final class PresetScope { public readonly string $kind; public readonly string $name; public function __construct(string $kind, string $name); public static function fromInput(array $item, string $field): self /* throws InvalidArgumentException */; public function label(): string; public function equals(self $other): bool }` – `$kind` ist `module` oder `group`, `$name` der volle Name (`divi/button`, `divi/font-body`).
|
||
- `final class PresetBook { public static function fromDivi(array $raw): self; public function data(): array; public function rev(): string; public function count(): int; public function items(PresetScope $scope): array /* id => item */; public function defaultId(PresetScope $scope): string; public function find(PresetScope $scope, string $idOrName): ?array /* throws InvalidArgumentException bei mehrdeutigem Namen */; public function idsNamed(PresetScope $scope, string $name): array; public function scopeOf(string $id): ?PresetScope; public function with(PresetScope $scope, array $item): self; public function withDefault(PresetScope $scope, string $id): self; public function without(PresetScope $scope, string $id): self; public function nestedUsers(string $groupPresetId): list<array{scope: PresetScope, id: string, name: string}>; public function toRest(): array; public static function newId(): string }`
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Design/PresetScopeTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class PresetScopeTest extends TestCase
|
||
{
|
||
public function testShortAndFullNames(): void
|
||
{
|
||
self::assertSame('divi/button', PresetScope::fromInput(['module' => 'button'], 'items[0]')->name);
|
||
self::assertSame('module', PresetScope::fromInput(['module' => 'divi/button'], 'items[0]')->kind);
|
||
$group = PresetScope::fromInput(['group' => 'font-body'], 'items[0]');
|
||
self::assertSame(['group', 'divi/font-body'], [$group->kind, $group->name]);
|
||
self::assertSame('Gruppen-Preset divi/font-body', $group->label());
|
||
self::assertTrue($group->equals(new PresetScope('group', 'divi/font-body')));
|
||
}
|
||
|
||
/** @return array<string, array{array<string, mixed>, string}> */
|
||
public static function invalid(): array
|
||
{
|
||
return [
|
||
'nothing' => [[], 'items[0]'],
|
||
'both' => [['module' => 'button', 'group' => 'font'], 'items[0]'],
|
||
'not a name' => [['module' => 'Button 1'], 'items[0].module'],
|
||
'not a string' => [['group' => 5], 'items[0].group'],
|
||
'two slashes' => [['module' => 'divi/a/b'], 'items[0].module'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @dataProvider invalid
|
||
* @param array<string, mixed> $item
|
||
*/
|
||
public function testInvalidInputNamesTheField(array $item, string $field): void
|
||
{
|
||
try {
|
||
PresetScope::fromInput($item, 'items[0]');
|
||
self::fail('expected an exception');
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringStartsWith($field . ':', $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Design/PresetBookTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetBook;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||
use PHPUnit\Framework\TestCase;
|
||
use stdClass;
|
||
|
||
final class PresetBookTest extends TestCase
|
||
{
|
||
/** @return array<string, mixed> Stored format like GlobalPreset::get_data(). */
|
||
public static function stored(): array
|
||
{
|
||
return [
|
||
'module' => [
|
||
'divi/button' => ['default' => '', 'items' => [
|
||
'aaaaaaaaaa' => ['type' => 'module', 'id' => 'aaaaaaaaaa', 'name' => 'CTA primär',
|
||
'moduleName' => 'divi/button', 'attrs' => ['button' => ['x' => 1]]],
|
||
'bbbbbbbbbb' => ['type' => 'module', 'id' => 'bbbbbbbbbb', 'name' => 'Doppelt',
|
||
'moduleName' => 'divi/button', 'attrs' => []],
|
||
'cccccccccc' => ['type' => 'module', 'id' => 'cccccccccc', 'name' => 'doppelt',
|
||
'moduleName' => 'divi/button', 'attrs' => []],
|
||
]],
|
||
'divi/text' => ['default' => 'dddddddddd', 'items' => [
|
||
'dddddddddd' => ['type' => 'module', 'id' => 'dddddddddd', 'name' => 'Fließtext',
|
||
'moduleName' => 'divi/text', 'attrs' => [],
|
||
'groupPresets' => ['content.decoration.bodyFont' => [
|
||
'presetId' => ['gggggggggg'], 'groupName' => 'divi/font-body']]],
|
||
]],
|
||
],
|
||
'group' => [
|
||
'divi/font-body' => ['default' => '', 'items' => [
|
||
'gggggggggg' => ['type' => 'group', 'id' => 'gggggggggg', 'name' => 'Lesetext',
|
||
'groupName' => 'divi/font-body', 'groupId' => 'content.decoration.bodyFont',
|
||
'moduleName' => 'divi/text', 'attrs' => []],
|
||
]],
|
||
],
|
||
];
|
||
}
|
||
|
||
public function testFindsByIdOrUniqueName(): void
|
||
{
|
||
$book = PresetBook::fromDivi(self::stored());
|
||
$button = new PresetScope('module', 'divi/button');
|
||
|
||
self::assertSame('aaaaaaaaaa', $book->find($button, 'aaaaaaaaaa')['id']);
|
||
self::assertSame('aaaaaaaaaa', $book->find($button, 'cta PRIMÄR')['id']);
|
||
self::assertNull($book->find($button, 'Fehlt'));
|
||
self::assertNull($book->find(new PresetScope('module', 'divi/text'), 'aaaaaaaaaa'));
|
||
self::assertSame(['bbbbbbbbbb', 'cccccccccc'], $book->idsNamed($button, 'DOPPELT'));
|
||
}
|
||
|
||
public function testAmbiguousNameListsTheIds(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage('Preset-Name "Doppelt" ist mehrdeutig; bitte die ID angeben: bbbbbbbbbb, cccccccccc.');
|
||
|
||
PresetBook::fromDivi(self::stored())->find(new PresetScope('module', 'divi/button'), 'Doppelt');
|
||
}
|
||
|
||
public function testStdClassGroupsBecomeArraysAndCountCoversBothKinds(): void
|
||
{
|
||
$raw = self::stored();
|
||
$raw['group'] = (object) ['divi/font-body' => (object) $raw['group']['divi/font-body']];
|
||
$book = PresetBook::fromDivi($raw);
|
||
|
||
self::assertSame(5, $book->count());
|
||
self::assertIsArray($book->data()['group']['divi/font-body']['items']);
|
||
self::assertSame('group', $book->scopeOf('gggggggggg')?->kind);
|
||
self::assertNull($book->scopeOf('zzzzzzzzzz'));
|
||
self::assertSame(0, PresetBook::fromDivi(new stdClass())->count());
|
||
}
|
||
|
||
public function testWithWithoutAndDefaultAreImmutable(): void
|
||
{
|
||
$book = PresetBook::fromDivi(self::stored());
|
||
$button = new PresetScope('module', 'divi/button');
|
||
$new = ['type' => 'module', 'id' => 'eeeeeeeeee', 'name' => 'Neu', 'moduleName' => 'divi/button', 'attrs' => []];
|
||
|
||
$added = $book->with($button, $new)->withDefault($button, 'eeeeeeeeee');
|
||
$removed = $added->without($button, 'eeeeeeeeee');
|
||
|
||
self::assertSame(5, $book->count());
|
||
self::assertSame(6, $added->count());
|
||
self::assertSame('eeeeeeeeee', $added->defaultId($button));
|
||
self::assertSame('', $removed->defaultId($button), 'removing the default clears it');
|
||
self::assertNotSame($book->rev(), $added->rev());
|
||
self::assertSame($book->rev(), PresetBook::fromDivi(self::stored())->rev());
|
||
self::assertSame('', $book->defaultId(new PresetScope('group', 'divi/spacing')));
|
||
}
|
||
|
||
public function testNestedUsersOfAGroupPreset(): void
|
||
{
|
||
$users = PresetBook::fromDivi(self::stored())->nestedUsers('gggggggggg');
|
||
|
||
self::assertCount(1, $users);
|
||
self::assertSame(['dddddddddd', 'Fließtext'], [$users[0]['id'], $users[0]['name']]);
|
||
self::assertSame('divi/text', $users[0]['scope']->name);
|
||
}
|
||
|
||
public function testRestFormatListsItemsPerName(): void
|
||
{
|
||
$rest = PresetBook::fromDivi(self::stored())->toRest();
|
||
|
||
self::assertSame('dddddddddd', $rest['module'][1]['default']);
|
||
self::assertSame(['aaaaaaaaaa', 'bbbbbbbbbb', 'cccccccccc'], array_column($rest['module'][0]['items'], 'id'));
|
||
self::assertSame('gggggggggg', $rest['group'][0]['items'][0]['id']);
|
||
self::assertTrue(array_is_list($rest['module']));
|
||
}
|
||
|
||
public function testNewIdsLookLikeTheBuildersIds(): void
|
||
{
|
||
self::assertMatchesRegularExpression('/^[0-9a-z]{10}$/', PresetBook::newId());
|
||
self::assertNotSame(PresetBook::newId(), PresetBook::newId());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – sie scheitern**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetScopeTest.php tests/Unit/Integrations/Divi/Design/PresetBookTest.php`
|
||
Expected: FAIL, `Class "LIT\MCP\Integrations\Divi\Design\PresetScope" not found`.
|
||
|
||
- [ ] **Step 3: `PresetScope` implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Where a preset lives (spec §6.1): a module preset ("module" + divi/<module>) or an option group preset
|
||
* ("group" + divi/<group>). Input takes the short ("button", "font-body") or the full name. Pure PHP.
|
||
*/
|
||
final class PresetScope
|
||
{
|
||
public const MODULE = 'module';
|
||
public const GROUP = 'group';
|
||
|
||
private const NAME = '/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/D';
|
||
|
||
public function __construct(public readonly string $kind, public readonly string $name)
|
||
{
|
||
}
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $item An input entry with exactly one of "module" or "group".
|
||
* @throws InvalidArgumentException "<field>: <problem>" for the input path.
|
||
*/
|
||
public static function fromInput(array $item, string $field): self
|
||
{
|
||
$kinds = array_values(array_filter(
|
||
[self::MODULE, self::GROUP],
|
||
static fn (string $kind): bool => array_key_exists($kind, $item)
|
||
));
|
||
if (count($kinds) !== 1) {
|
||
throw new InvalidArgumentException($field . ': genau eines von module oder group angeben');
|
||
}
|
||
|
||
$kind = $kinds[0];
|
||
$value = $item[$kind];
|
||
$name = is_string($value) ? (str_contains($value, '/') ? $value : 'divi/' . $value) : '';
|
||
if (preg_match(self::NAME, $name) !== 1) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
'%s.%s: Name wie "button" bzw. "divi/button" (Gruppe: "font-body") erwartet',
|
||
$field,
|
||
$kind
|
||
));
|
||
}
|
||
|
||
return new self($kind, $name);
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return ($this->kind === self::MODULE ? 'Modul-Preset ' : 'Gruppen-Preset ') . $this->name;
|
||
}
|
||
|
||
public function equals(self $other): bool
|
||
{
|
||
return $this->kind === $other->kind && $this->name === $other->name;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: `PresetBook` implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Abilities\Rev;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The preset inventory in Divi's stored format (GlobalPreset::get_data(), formats §3):
|
||
* {module|group: {<name>: {default: id|"", items: {id: item}}}}. Immutable – with()/without()/withDefault() return a
|
||
* new book. rev() is the design_rev of the area "presets" (spec §2). toRest() gives the REST/sync format
|
||
* ({module|group: list<{default, items: list}>}) that GlobalPreset::prepare_data() takes. Pure PHP.
|
||
*/
|
||
final class PresetBook
|
||
{
|
||
private const KINDS = [PresetScope::MODULE, PresetScope::GROUP];
|
||
|
||
private const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||
|
||
/** @param array{module: array<string, array{default: string, items: array<string, array<string, mixed>>}>,
|
||
* group: array<string, array{default: string, items: array<string, array<string, mixed>>}>} $data */
|
||
private function __construct(private array $data)
|
||
{
|
||
}
|
||
|
||
/** @param array<array-key, mixed>|object $raw GlobalPreset::get_data() – groups may be stdClass. */
|
||
public static function fromDivi(array|object $raw): self
|
||
{
|
||
$raw = self::plain($raw);
|
||
$data = [PresetScope::MODULE => [], PresetScope::GROUP => []];
|
||
foreach (self::KINDS as $kind) {
|
||
foreach (is_array($raw[$kind] ?? null) ? $raw[$kind] : [] as $name => $record) {
|
||
if (! is_array($record)) {
|
||
continue;
|
||
}
|
||
$items = [];
|
||
foreach (is_array($record['items'] ?? null) ? $record['items'] : [] as $id => $item) {
|
||
if (is_array($item)) {
|
||
$items[(string) $id] = $item;
|
||
}
|
||
}
|
||
$data[$kind][(string) $name] = [
|
||
'default' => is_string($record['default'] ?? null) ? $record['default'] : '',
|
||
'items' => $items,
|
||
];
|
||
}
|
||
}
|
||
|
||
return new self($data);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public function data(): array
|
||
{
|
||
return $this->data;
|
||
}
|
||
|
||
public function rev(): string
|
||
{
|
||
return Rev::of('presets' . "\0" . (string) json_encode($this->data, JSON_UNESCAPED_UNICODE));
|
||
}
|
||
|
||
public function count(): int
|
||
{
|
||
$count = 0;
|
||
foreach ($this->data as $records) {
|
||
foreach ($records as $record) {
|
||
$count += count($record['items']);
|
||
}
|
||
}
|
||
|
||
return $count;
|
||
}
|
||
|
||
/** @return array<string, array<string, mixed>> id => item */
|
||
public function items(PresetScope $scope): array
|
||
{
|
||
return $this->data[$scope->kind][$scope->name]['items'] ?? [];
|
||
}
|
||
|
||
public function defaultId(PresetScope $scope): string
|
||
{
|
||
return $this->data[$scope->kind][$scope->name]['default'] ?? '';
|
||
}
|
||
|
||
/**
|
||
* The preset with ID $idOrName, else the one preset named $idOrName (case-insensitive).
|
||
*
|
||
* @return array<string, mixed>|null
|
||
* @throws InvalidArgumentException When several presets of $scope share the name (ruling S13a).
|
||
*/
|
||
public function find(PresetScope $scope, string $idOrName): ?array
|
||
{
|
||
$items = $this->items($scope);
|
||
if (isset($items[$idOrName])) {
|
||
return $items[$idOrName];
|
||
}
|
||
|
||
$ids = $this->idsNamed($scope, $idOrName);
|
||
if (count($ids) > 1) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
'Preset-Name "%s" ist mehrdeutig; bitte die ID angeben: %s.',
|
||
trim($idOrName),
|
||
implode(', ', $ids)
|
||
));
|
||
}
|
||
|
||
return $ids === [] ? null : $items[$ids[0]];
|
||
}
|
||
|
||
/** @return list<string> IDs of the presets of $scope named $name (case-insensitive, trimmed). */
|
||
public function idsNamed(PresetScope $scope, string $name): array
|
||
{
|
||
$key = mb_strtolower(trim($name));
|
||
$ids = [];
|
||
foreach ($this->items($scope) as $id => $item) {
|
||
if (is_string($item['name'] ?? null) && mb_strtolower(trim($item['name'])) === $key) {
|
||
$ids[] = (string) $id;
|
||
}
|
||
}
|
||
|
||
return $ids;
|
||
}
|
||
|
||
public function scopeOf(string $id): ?PresetScope
|
||
{
|
||
foreach ($this->data as $kind => $records) {
|
||
foreach ($records as $name => $record) {
|
||
if (isset($record['items'][$id])) {
|
||
return new PresetScope($kind, $name);
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/** @param array<string, mixed> $item Needs a string "id"; added or replaced. */
|
||
public function with(PresetScope $scope, array $item): self
|
||
{
|
||
$data = $this->data;
|
||
$data[$scope->kind][$scope->name] ??= ['default' => '', 'items' => []];
|
||
$data[$scope->kind][$scope->name]['items'][(string) $item['id']] = $item;
|
||
|
||
return new self($data);
|
||
}
|
||
|
||
public function withDefault(PresetScope $scope, string $id): self
|
||
{
|
||
$data = $this->data;
|
||
$data[$scope->kind][$scope->name] ??= ['default' => '', 'items' => []];
|
||
$data[$scope->kind][$scope->name]['default'] = $id;
|
||
|
||
return new self($data);
|
||
}
|
||
|
||
/** Removes the preset; a default that pointed to it becomes "" (spec §6.4). */
|
||
public function without(PresetScope $scope, string $id): self
|
||
{
|
||
$data = $this->data;
|
||
unset($data[$scope->kind][$scope->name]['items'][$id]);
|
||
if (($data[$scope->kind][$scope->name]['default'] ?? '') === $id) {
|
||
$data[$scope->kind][$scope->name]['default'] = '';
|
||
}
|
||
|
||
return new self($data);
|
||
}
|
||
|
||
/**
|
||
* Presets that nest group preset $groupPresetId in their groupPresets (formats §3).
|
||
*
|
||
* @return list<array{scope: PresetScope, id: string, name: string}>
|
||
*/
|
||
public function nestedUsers(string $groupPresetId): array
|
||
{
|
||
$users = [];
|
||
foreach ($this->data as $kind => $records) {
|
||
foreach ($records as $name => $record) {
|
||
foreach ($record['items'] as $id => $item) {
|
||
foreach (is_array($item['groupPresets'] ?? null) ? $item['groupPresets'] : [] as $nested) {
|
||
$ids = is_array($nested) ? (array) ($nested['presetId'] ?? []) : [];
|
||
if (in_array($groupPresetId, $ids, true)) {
|
||
$users[] = [
|
||
'scope' => new PresetScope($kind, $name),
|
||
'id' => (string) $id,
|
||
'name' => is_string($item['name'] ?? null) ? $item['name'] : (string) $id,
|
||
];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return $users;
|
||
}
|
||
|
||
/** @return array{module: list<array{default: string, items: list<array<string, mixed>>}>,
|
||
* group: list<array{default: string, items: list<array<string, mixed>>}>} */
|
||
public function toRest(): array
|
||
{
|
||
$rest = [PresetScope::MODULE => [], PresetScope::GROUP => []];
|
||
foreach ($this->data as $kind => $records) {
|
||
foreach ($records as $record) {
|
||
$rest[$kind][] = ['default' => $record['default'], 'items' => array_values($record['items'])];
|
||
}
|
||
}
|
||
|
||
return $rest;
|
||
}
|
||
|
||
/** A new preset ID like the Visual Builder's generateNanoUuid(): 10 characters of [0-9a-z] (formats §3). */
|
||
public static function newId(): string
|
||
{
|
||
$id = '';
|
||
for ($i = 0; $i < 10; ++$i) {
|
||
$id .= self::ALPHABET[random_int(0, 35)];
|
||
}
|
||
|
||
return $id;
|
||
}
|
||
|
||
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 5: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetScopeTest.php tests/Unit/Integrations/Divi/Design/PresetBookTest.php`
|
||
Expected: PASS (alle Tests grün).
|
||
|
||
- [ ] **Step 6: Gesamtlauf und Lint**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK, keine phpcs-Fehler.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/PresetScope.php src/Integrations/Divi/Design/PresetBook.php tests/Unit/Integrations/Divi/Design/PresetScopeTest.php tests/Unit/Integrations/Divi/Design/PresetBookTest.php
|
||
git commit -m "feat: PresetScope und PresetBook – Preset-Bestand im Divi-Speicherformat" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `PresetDiff` – Unterschied zweier Preset-Bestände
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/PresetDiff.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/PresetDiffTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `PresetBook::data()`, `PresetScope` (Task 2).
|
||
- Produces: `final class PresetDiff { public static function between(PresetBook $before, PresetBook $after): list<array{kind: string, scope: string, id: string, name: string, action: string, fields?: list<string>}> }` – `action` ∈ `added`, `changed`, `removed`, `default_set`, `default_cleared`; bei `changed` listet `fields` die geänderten Schlüssel der obersten Ebene (`attrs`, `name`, `groupPresets` …; `updated`, `styleAttrs`, `renderAttrs` und `version` zählen nicht).
|
||
|
||
- [ ] **Step 1: Failing test schreiben**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\PresetBook;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetDiff;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class PresetDiffTest extends TestCase
|
||
{
|
||
public function testNoChangeIsAnEmptyDiff(): void
|
||
{
|
||
$book = PresetBook::fromDivi(PresetBookTest::stored());
|
||
|
||
self::assertSame([], PresetDiff::between($book, PresetBook::fromDivi(PresetBookTest::stored())));
|
||
}
|
||
|
||
public function testAddedChangedRemovedAndDefaults(): void
|
||
{
|
||
$before = PresetBook::fromDivi(PresetBookTest::stored());
|
||
$button = new PresetScope('module', 'divi/button');
|
||
$text = new PresetScope('module', 'divi/text');
|
||
$changed = $before->items($button)['aaaaaaaaaa'];
|
||
$changed['attrs'] = ['button' => ['x' => 2]];
|
||
$changed['updated'] = 99;
|
||
$changed['styleAttrs'] = ['ignored' => true];
|
||
$after = $before
|
||
->with($button, $changed)
|
||
->with($button, ['id' => 'eeeeeeeeee', 'name' => 'Neu', 'attrs' => []])
|
||
->withDefault($button, 'eeeeeeeeee')
|
||
->without($button, 'bbbbbbbbbb')
|
||
->withDefault($text, '');
|
||
|
||
self::assertSame([
|
||
['kind' => 'module', 'scope' => 'divi/button', 'id' => 'aaaaaaaaaa', 'name' => 'CTA primär',
|
||
'action' => 'changed', 'fields' => ['attrs']],
|
||
['kind' => 'module', 'scope' => 'divi/button', 'id' => 'bbbbbbbbbb', 'name' => 'Doppelt',
|
||
'action' => 'removed'],
|
||
['kind' => 'module', 'scope' => 'divi/button', 'id' => 'eeeeeeeeee', 'name' => 'Neu', 'action' => 'added'],
|
||
['kind' => 'module', 'scope' => 'divi/button', 'id' => 'eeeeeeeeee', 'name' => 'Neu',
|
||
'action' => 'default_set'],
|
||
['kind' => 'module', 'scope' => 'divi/text', 'id' => 'dddddddddd', 'name' => 'Fließtext',
|
||
'action' => 'default_cleared'],
|
||
], PresetDiff::between($before, $after));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen – er scheitert**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetDiffTest.php`
|
||
Expected: FAIL, `Class "LIT\MCP\Integrations\Divi\Design\PresetDiff" not found`.
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* What a preset change does (spec §4 step 4, "diff"): per preset added, changed (with the changed top-level keys),
|
||
* removed, and per module/group a default that is set or cleared. Keys the plugin derives or Divi maintains
|
||
* (updated, styleAttrs, renderAttrs, version) are no change of their own. Sorted by kind, scope, id, action.
|
||
* Pure PHP.
|
||
*/
|
||
final class PresetDiff
|
||
{
|
||
private const DERIVED = ['updated', 'styleAttrs', 'renderAttrs', 'version'];
|
||
|
||
/** @return list<array{kind: string, scope: string, id: string, name: string, action: string, fields?: list<string>}> */
|
||
public static function between(PresetBook $before, PresetBook $after): array
|
||
{
|
||
$old = $before->data();
|
||
$new = $after->data();
|
||
$rows = [];
|
||
foreach ([PresetScope::MODULE, PresetScope::GROUP] as $kind) {
|
||
$names = array_unique(array_merge(array_keys($old[$kind] ?? []), array_keys($new[$kind] ?? [])));
|
||
sort($names);
|
||
foreach ($names as $name) {
|
||
$was = $old[$kind][$name] ?? ['default' => '', 'items' => []];
|
||
$is = $new[$kind][$name] ?? ['default' => '', 'items' => []];
|
||
array_push($rows, ...self::items($kind, (string) $name, $was['items'], $is['items']));
|
||
array_push($rows, ...self::defaults($kind, (string) $name, $was, $is));
|
||
}
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, array<string, mixed>> $was
|
||
* @param array<string, array<string, mixed>> $is
|
||
* @return list<array<string, mixed>>
|
||
*/
|
||
private static function items(string $kind, string $scope, array $was, array $is): array
|
||
{
|
||
$ids = array_unique(array_merge(array_keys($was), array_keys($is)));
|
||
sort($ids);
|
||
$rows = [];
|
||
foreach ($ids as $id) {
|
||
$id = (string) $id;
|
||
$before = $was[$id] ?? null;
|
||
$after = $is[$id] ?? null;
|
||
if ($before === null || $after === null) {
|
||
$rows[] = self::row($kind, $scope, $id, $after ?? $before, $before === null ? 'added' : 'removed');
|
||
continue;
|
||
}
|
||
$fields = self::changedFields($before, $after);
|
||
if ($fields !== []) {
|
||
$rows[] = self::row($kind, $scope, $id, $after, 'changed') + ['fields' => $fields];
|
||
}
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* @param array{default: string, items: array<string, array<string, mixed>>} $was
|
||
* @param array{default: string, items: array<string, array<string, mixed>>} $is
|
||
* @return list<array<string, mixed>>
|
||
*/
|
||
private static function defaults(string $kind, string $scope, array $was, array $is): array
|
||
{
|
||
if ($was['default'] === $is['default']) {
|
||
return [];
|
||
}
|
||
if ($is['default'] === '') {
|
||
return [self::row($kind, $scope, $was['default'], $was['items'][$was['default']] ?? null, 'default_cleared')];
|
||
}
|
||
|
||
return [self::row($kind, $scope, $is['default'], $is['items'][$is['default']] ?? null, 'default_set')];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $before
|
||
* @param array<string, mixed> $after
|
||
* @return list<string>
|
||
*/
|
||
private static function changedFields(array $before, array $after): array
|
||
{
|
||
$keys = array_diff(array_unique(array_merge(array_keys($before), array_keys($after))), self::DERIVED);
|
||
$fields = [];
|
||
foreach ($keys as $key) {
|
||
if (($before[$key] ?? null) != ($after[$key] ?? null)) { // phpcs:ignore Universal.Operators.StrictComparisons
|
||
$fields[] = (string) $key;
|
||
}
|
||
}
|
||
sort($fields);
|
||
|
||
return $fields;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed>|null $item
|
||
* @return array{kind: string, scope: string, id: string, name: string, action: string}
|
||
*/
|
||
private static function row(string $kind, string $scope, string $id, ?array $item, string $action): array
|
||
{
|
||
return [
|
||
'kind' => $kind,
|
||
'scope' => $scope,
|
||
'id' => $id,
|
||
'name' => is_string($item['name'] ?? null) ? $item['name'] : $id,
|
||
'action' => $action,
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
Hinweis zur losen Gleichheit in `changedFields()`: Divi speichert Zahlen mal als String, mal als Zahl (`order: "1"`); `!=` vergleicht Arrays rekursiv ohne Typ- und Schlüsselreihenfolge-Strenge – genau das gewünschte „inhaltlich gleich“. Meldet phpcs eine andere Regel als `Universal.Operators.StrictComparisons`, den Regelnamen im `phpcs:ignore` daran anpassen.
|
||
|
||
Die erwartete Reihenfolge im Test folgt aus der Sortierung: je Bereich zuerst alle Einträge nach ID (`aaaaaaaaaa` changed, `bbbbbbbbbb` removed, `eeeeeeeeee` added), dann der Standard.
|
||
|
||
- [ ] **Step 4: Test laufen lassen – er besteht**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetDiffTest.php`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Gesamtlauf, Lint, Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/PresetDiff.php tests/Unit/Integrations/Divi/Design/PresetDiffTest.php
|
||
git commit -m "feat: PresetDiff – Unterschied zweier Preset-Bestände für den Probelauf" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `SnapshotStore` – Snapshots je Bereich (Spec §5)
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/SnapshotStore.php`
|
||
- Create: `src/Integrations/Divi/Design/SnapshotTooLarge.php`
|
||
- Modify: `src/Uninstaller.php` (Konstante `OPTIONS`)
|
||
- Test: `tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php`
|
||
- Test: `tests/Unit/UninstallerTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: WordPress `get_option`, `update_option`, `get_current_user_id`, `gmdate`.
|
||
- Produces:
|
||
- `class SnapshotStore { public const AREAS = ['colors', 'variables', 'presets', 'theme_options']; public const LIMIT = 20; public const MAX_BYTES = 2000000; public static function option(string $area): string; public function save(string $area, array $data, string $ability): string /* id; throws SnapshotTooLarge, RuntimeException */; public function attachLog(string $area, string $id, ?int $logId): void; public function list(string $area): list<array{id: string, created: string, user_id: int, ability: string, log_id: ?int, size: int}>; public function get(string $area, string $id): ?array /* Eintrag mit data */ }` – nicht final (Mocks in Ability-Tests).
|
||
- `final class SnapshotTooLarge extends RuntimeException { public function __construct(public readonly int $size) }`
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotTooLarge;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use RuntimeException;
|
||
|
||
final class SnapshotStoreTest extends TestCase
|
||
{
|
||
/** @var array<string, mixed> */
|
||
private array $options = [];
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->options = [];
|
||
Functions\when('get_option')->alias(fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default);
|
||
Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool {
|
||
self::assertFalse($autoload, 'snapshots are never autoloaded');
|
||
$this->options[$name] = $value;
|
||
|
||
return true;
|
||
});
|
||
Functions\when('get_current_user_id')->justReturn(7);
|
||
}
|
||
|
||
public function testSavesListsAndReturnsTheNewestFirst(): void
|
||
{
|
||
$store = new SnapshotStore();
|
||
$first = $store->save('presets', ['module' => ['a' => 1]], 'lit/divi-presets-set');
|
||
$second = $store->save('presets', ['module' => ['a' => 2]], 'lit/divi-presets-set');
|
||
$store->attachLog('presets', $second, 42);
|
||
|
||
self::assertSame('lit_mcp_snapshots_presets', SnapshotStore::option('presets'));
|
||
self::assertMatchesRegularExpression('/^presets-\d{8}T\d{6}Z-[0-9a-f]{6}$/', $first);
|
||
$list = $store->list('presets');
|
||
self::assertSame([$second, $first], array_column($list, 'id'));
|
||
self::assertSame(42, $list[0]['log_id']);
|
||
self::assertSame(7, $list[0]['user_id']);
|
||
self::assertArrayNotHasKey('data', $list[0]);
|
||
self::assertSame(['module' => ['a' => 1]], $store->get('presets', $first)['data']);
|
||
self::assertNull($store->get('presets', 'presets-unknown'));
|
||
self::assertSame([], $store->list('colors'));
|
||
}
|
||
|
||
public function testKeepsAtMostTwentyPerArea(): void
|
||
{
|
||
$store = new SnapshotStore();
|
||
$ids = [];
|
||
for ($i = 0; $i < 22; ++$i) {
|
||
$ids[] = $store->save('presets', ['n' => $i], 'lit/divi-presets-set');
|
||
}
|
||
|
||
$list = $store->list('presets');
|
||
self::assertCount(SnapshotStore::LIMIT, $list);
|
||
self::assertSame($ids[21], $list[0]['id']);
|
||
self::assertNull($store->get('presets', $ids[0]));
|
||
self::assertNull($store->get('presets', $ids[1]));
|
||
}
|
||
|
||
public function testRefusesSnapshotsAboveTheLimit(): void
|
||
{
|
||
$this->expectException(SnapshotTooLarge::class);
|
||
|
||
(new SnapshotStore())->save('presets', ['x' => str_repeat('a', SnapshotStore::MAX_BYTES)], 'lit/divi-presets-set');
|
||
}
|
||
|
||
public function testUnknownAreaIsAProgrammingError(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
|
||
SnapshotStore::option('pages');
|
||
}
|
||
|
||
public function testAFailedOptionWriteThrows(): void
|
||
{
|
||
Functions\when('update_option')->justReturn(false);
|
||
$this->expectException(RuntimeException::class);
|
||
$this->expectExceptionMessage('snapshot presets could not be stored');
|
||
|
||
(new SnapshotStore())->save('presets', ['a' => 1], 'lit/divi-presets-set');
|
||
}
|
||
|
||
public function testCorruptOptionReadsAsEmpty(): void
|
||
{
|
||
$this->options['lit_mcp_snapshots_presets'] = 'kaputt';
|
||
|
||
self::assertSame([], (new SnapshotStore())->list('presets'));
|
||
}
|
||
}
|
||
```
|
||
|
||
`update_option()` liefert `false`, wenn der Wert unverändert ist. Ein neuer Snapshot ändert die Liste immer (neue ID am Anfang), daher ist `false` hier ein echter Fehler.
|
||
|
||
In `tests/Unit/UninstallerTest.php` den Test, der die gelöschten Optionen prüft, um die vier Snapshot-Optionen ergänzen. Dafür in der Liste der erwarteten Optionen (Suche nach `'lit_mcp_divi_check_lock'`) anhängen:
|
||
|
||
```php
|
||
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
|
||
'lit_mcp_snapshots_theme_options',
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – sie scheitern**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php tests/Unit/UninstallerTest.php`
|
||
Expected: FAIL (`SnapshotStore` fehlt; Uninstaller löscht die neuen Optionen nicht).
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`src/Integrations/Divi/Design/SnapshotTooLarge.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use RuntimeException;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/** A snapshot above SnapshotStore::MAX_BYTES (spec §5): the change is refused instead of writing without a backup. */
|
||
final class SnapshotTooLarge extends RuntimeException
|
||
{
|
||
public function __construct(public readonly int $size)
|
||
{
|
||
parent::__construct(sprintf('snapshot of %d bytes exceeds %d', $size, SnapshotStore::MAX_BYTES));
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Divi/Design/SnapshotStore.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use RuntimeException;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Snapshots of the design system (spec §5, G6): one option per area (lit_mcp_snapshots_<area>, never autoloaded),
|
||
* newest first, at most LIMIT entries – the oldest falls out. An entry keeps the area's raw inventory as Divi stores
|
||
* it plus who made it with which tool and the change log entry. The only way back for colors, variables and presets:
|
||
* Divi 5 keeps no server-side history (formats §7.6). Not final so abilities can be tested with a mock.
|
||
*/
|
||
class SnapshotStore
|
||
{
|
||
public const AREAS = ['colors', 'variables', 'presets', 'theme_options'];
|
||
|
||
public const LIMIT = 20;
|
||
|
||
/** Spec §5: a larger snapshot refuses the change. */
|
||
public const MAX_BYTES = 2000000;
|
||
|
||
private const PREFIX = 'lit_mcp_snapshots_';
|
||
|
||
public static function option(string $area): string
|
||
{
|
||
if (! in_array($area, self::AREAS, true)) {
|
||
throw new InvalidArgumentException(sprintf('Unknown design area "%s".', $area));
|
||
}
|
||
|
||
return self::PREFIX . $area;
|
||
}
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $data The area's raw inventory.
|
||
* @throws SnapshotTooLarge
|
||
* @throws RuntimeException When the option could not be written.
|
||
*/
|
||
public function save(string $area, array $data, string $ability): string
|
||
{
|
||
$size = strlen((string) json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||
if ($size > self::MAX_BYTES) {
|
||
throw new SnapshotTooLarge($size);
|
||
}
|
||
|
||
$id = sprintf('%s-%s-%s', $area, gmdate('Ymd\THis\Z'), bin2hex(random_bytes(3)));
|
||
$entries = $this->entries($area);
|
||
array_unshift($entries, [
|
||
'id' => $id,
|
||
'created' => gmdate('c'),
|
||
'user_id' => get_current_user_id(),
|
||
'ability' => $ability,
|
||
'log_id' => null,
|
||
'size' => $size,
|
||
'data' => $data,
|
||
]);
|
||
if (! update_option(self::option($area), array_slice($entries, 0, self::LIMIT), false)) {
|
||
throw new RuntimeException(sprintf('snapshot %s could not be stored', $area));
|
||
}
|
||
|
||
return $id;
|
||
}
|
||
|
||
/** Notes the change log entry of the change the snapshot belongs to (the log entry is written after it). */
|
||
public function attachLog(string $area, string $id, ?int $logId): void
|
||
{
|
||
if ($logId === null) {
|
||
return;
|
||
}
|
||
|
||
$entries = $this->entries($area);
|
||
foreach ($entries as $index => $entry) {
|
||
if ($entry['id'] === $id) {
|
||
$entries[$index]['log_id'] = $logId;
|
||
update_option(self::option($area), $entries, false);
|
||
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** @return list<array{id: string, created: string, user_id: int, ability: string, log_id: ?int, size: int}> */
|
||
public function list(string $area): array
|
||
{
|
||
return array_map(static function (array $entry): array {
|
||
unset($entry['data']);
|
||
|
||
return $entry;
|
||
}, $this->entries($area));
|
||
}
|
||
|
||
/** @return array{id: string, created: string, user_id: int, ability: string, log_id: ?int, size: int,
|
||
* data: array<array-key, mixed>}|null */
|
||
public function get(string $area, string $id): ?array
|
||
{
|
||
foreach ($this->entries($area) as $entry) {
|
||
if ($entry['id'] === $id) {
|
||
return $entry;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/** @return list<array{id: string, created: string, user_id: int, ability: string, log_id: ?int, size: int,
|
||
* data: array<array-key, mixed>}> */
|
||
private function entries(string $area): array
|
||
{
|
||
$stored = get_option(self::option($area), []);
|
||
if (! is_array($stored)) {
|
||
return [];
|
||
}
|
||
|
||
$entries = [];
|
||
foreach ($stored as $entry) {
|
||
if (is_array($entry) && is_string($entry['id'] ?? null) && is_array($entry['data'] ?? null)) {
|
||
$entries[] = [
|
||
'id' => $entry['id'],
|
||
'created' => (string) ($entry['created'] ?? ''),
|
||
'user_id' => (int) ($entry['user_id'] ?? 0),
|
||
'ability' => (string) ($entry['ability'] ?? ''),
|
||
'log_id' => isset($entry['log_id']) ? (int) $entry['log_id'] : null,
|
||
'size' => (int) ($entry['size'] ?? 0),
|
||
'data' => $entry['data'],
|
||
];
|
||
}
|
||
}
|
||
|
||
return $entries;
|
||
}
|
||
}
|
||
```
|
||
|
||
In `src/Uninstaller.php` die Konstante `OPTIONS` erweitern:
|
||
|
||
```php
|
||
private const OPTIONS = [
|
||
'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version',
|
||
'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock',
|
||
'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets',
|
||
'lit_mcp_snapshots_theme_options',
|
||
];
|
||
```
|
||
|
||
Den Docblock von `Uninstaller` um „Snapshots des Designsystems (Etappe 4)“ in der Aufzählung der entfernten Daten ergänzen.
|
||
|
||
- [ ] **Step 4: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php tests/Unit/UninstallerTest.php`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Gesamtlauf, Lint, Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/SnapshotStore.php src/Integrations/Divi/Design/SnapshotTooLarge.php src/Uninstaller.php tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php tests/Unit/UninstallerTest.php
|
||
git commit -m "feat: SnapshotStore – Snapshots des Designsystems je Bereich" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
### Task 5: Adapter `DesignSystemWriter` – Presets über Divis Sync-Ablauf schreiben
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/DesignSystemWriter.php`
|
||
- Modify: `src/Integrations/Divi/Release/DiviAdapters.php`
|
||
- Modify: `src/Integrations/Divi/Release/fingerprints.json` (per WP-CLI erzeugt)
|
||
- Create: `tests/Fixtures/divi/reference/preset-sync.txt` (per WP-CLI erzeugt)
|
||
- Test: `tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ReleaseGate::assertWritable()`, `Manifest`, `Call`, `Mirror` (vorhanden).
|
||
- Produces: `class DesignSystemWriter implements DiviAdapter` (nicht final) mit
|
||
- `public function presets(): array` – roher Bestand (`GlobalPreset::get_data()`).
|
||
- `public function savePresets(array $rest, bool $mayRemove): array` – REST-Format (`PresetBook::toRest()`) speichern wie Divis Sync; `$mayRemove` erlaubt weniger Presets (Löschen, Wiederherstellen) und bereinigt dann Divis Divi-4-Altoption; gibt den gespeicherten Bestand zurück; `@throws DiviLocked`, `@throws RuntimeException` (Divi meldet einen Fehler).
|
||
- `public function presetAttrs(string $moduleName, array $attrs): array{all: array, style: array, render: array}` – Teilmengen nach Divis Preset-Mapping.
|
||
- `public function canUseVisualBuilder(): bool`.
|
||
- Konstanten `ACTION_SAVE = 'ADD_NEW_MODULE_PRESET'`, `ACTION_DELETE_MODULE = 'DELETE_MODULE_PRESET'`, `ACTION_DELETE_GROUP = 'DELETE_OPTION_GROUP_PRESET'`, `PRESET_TYPES = ['style', 'html', 'script']`.
|
||
|
||
- [ ] **Step 1: Failing test schreiben**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class DesignSystemWriterTest extends TestCase
|
||
{
|
||
public function testIsARegisteredDesignAdapterWithTheSyncMirror(): void
|
||
{
|
||
$manifest = DesignSystemWriter::manifest();
|
||
|
||
self::assertContains(DesignSystemWriter::class, DiviAdapters::CLASSES);
|
||
self::assertSame(Manifest::DESIGN, $manifest->area);
|
||
self::assertSame(
|
||
[
|
||
'GlobalPreset::get_data()', 'GlobalPreset::prepare_data()', 'GlobalPreset::validate_preset_count()',
|
||
'GlobalPreset::get_deleted_preset_ids()', 'GlobalPreset::remove_preset_from_legacy_option()',
|
||
'GlobalPreset::save_data()', 'Conversion::get_preset_attrs_mapping()', 'Conversion::get_preset_attrs()',
|
||
'UserRole::can_current_user_use_visual_builder()',
|
||
],
|
||
array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
|
||
);
|
||
self::assertSame(['preset-sync'], array_map(static fn ($mirror): string => $mirror->id, $manifest->mirrors));
|
||
self::assertSame('GlobalPresetController', $manifest->mirrors[0]->class);
|
||
}
|
||
|
||
public function testWritesNeedAReleasedDivi(): void
|
||
{
|
||
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.16.0'));
|
||
|
||
$this->expectException(DiviLocked::class);
|
||
|
||
(new DesignSystemWriter())->savePresets(['module' => [], 'group' => []], false);
|
||
}
|
||
|
||
public function testWithoutDiviNobodyUsesTheVisualBuilder(): void
|
||
{
|
||
self::assertFalse((new DesignSystemWriter())->canUseVisualBuilder());
|
||
}
|
||
}
|
||
```
|
||
|
||
`UserRole` ist im Unit-Test nicht geladen; `canUseVisualBuilder()` prüft `class_exists()` und antwortet dann `false` (fail closed).
|
||
|
||
- [ ] **Step 2: Test laufen lassen – er scheitert**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php`
|
||
Expected: FAIL, Klasse fehlt.
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use ET\Builder\Framework\UserRole\UserRole;
|
||
use ET\Builder\Packages\Conversion\Conversion;
|
||
use ET\Builder\Packages\GlobalData\GlobalPreset;
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
use LIT\MCP\Integrations\Divi\Release\Mirror;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use RuntimeException;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Adapter "Designsystem" (spec §3) for writing (Etappe 4): presets are saved the way Divi's own sync route does it
|
||
* (mirror preset-sync of GlobalPresetController::sync, formats "Nachtrag 5.15.1"): prepare_data() sanitizes the REST
|
||
* format, validate_preset_count() refuses a smaller inventory unless presets may be removed, removed presets leave
|
||
* Divi's Divi-4 legacy option, save_data() writes the option and clears the static CSS. Chunks and the route's nonce
|
||
* are not needed in-process; the tools check the rights themselves (spec §10). Also Divi's preset attribute mapping
|
||
* (which attributes a preset may carry) and the Visual Builder role right. Not final so abilities can be tested with
|
||
* a mock.
|
||
*/
|
||
class DesignSystemWriter implements DiviAdapter
|
||
{
|
||
public const ACTION_SAVE = 'ADD_NEW_MODULE_PRESET';
|
||
|
||
public const ACTION_DELETE_MODULE = 'DELETE_MODULE_PRESET';
|
||
|
||
public const ACTION_DELETE_GROUP = 'DELETE_OPTION_GROUP_PRESET';
|
||
|
||
/** Preset attribute types Divi copies into a preset (Conversion::get_preset_attrs). */
|
||
public const PRESET_TYPES = ['style', 'html', 'script'];
|
||
|
||
public static function manifest(): Manifest
|
||
{
|
||
return new Manifest(
|
||
Manifest::DESIGN,
|
||
calls: [
|
||
new Call(GlobalPreset::class . '::get_data', 0),
|
||
new Call(GlobalPreset::class . '::prepare_data', 1),
|
||
new Call(GlobalPreset::class . '::validate_preset_count', 3),
|
||
new Call(GlobalPreset::class . '::get_deleted_preset_ids', 3),
|
||
new Call(GlobalPreset::class . '::remove_preset_from_legacy_option', 2),
|
||
new Call(GlobalPreset::class . '::save_data', 1),
|
||
new Call(Conversion::class . '::get_preset_attrs_mapping', 1),
|
||
new Call(Conversion::class . '::get_preset_attrs', 3),
|
||
new Call(UserRole::class . '::can_current_user_use_visual_builder', 0),
|
||
],
|
||
mirrors: [
|
||
new Mirror(
|
||
'preset-sync',
|
||
'includes/builder-5/server/Packages/GlobalData/GlobalPresetController.php',
|
||
'sync',
|
||
'GlobalPresetController',
|
||
'Speicherablauf der Presets (prepare_data, validate_preset_count, Bereinigung der Divi-4-Option '
|
||
. 'beim Löschen, save_data)'
|
||
),
|
||
]
|
||
);
|
||
}
|
||
|
||
/** @return array<array-key, mixed> The stored inventory (GlobalPreset::get_data(), formats §3). */
|
||
public function presets(): array
|
||
{
|
||
$data = GlobalPreset::get_data();
|
||
|
||
return is_array($data) ? $data : [];
|
||
}
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $rest REST/sync format, see PresetBook::toRest().
|
||
* @param bool $mayRemove Deleting or restoring: fewer presets are allowed and removed ones
|
||
* leave Divi's legacy option (like a DELETE_* sync).
|
||
* @return array<array-key, mixed> The inventory Divi stored.
|
||
* @throws DiviLocked
|
||
* @throws RuntimeException When Divi refuses the inventory.
|
||
*/
|
||
public function savePresets(array $rest, bool $mayRemove): array
|
||
{
|
||
ReleaseGate::assertWritable();
|
||
$prepared = GlobalPreset::prepare_data($rest);
|
||
$current = GlobalPreset::get_data();
|
||
$refusal = GlobalPreset::validate_preset_count(
|
||
$current,
|
||
$prepared,
|
||
$mayRemove ? self::ACTION_DELETE_MODULE : self::ACTION_SAVE
|
||
);
|
||
if ($refusal instanceof WP_Error) {
|
||
throw new RuntimeException('preset sync refused: ' . $refusal->get_error_code() . ' '
|
||
. $refusal->get_error_message());
|
||
}
|
||
|
||
if ($mayRemove) {
|
||
foreach (['module', 'group'] as $kind) {
|
||
foreach (GlobalPreset::get_deleted_preset_ids($current, $prepared, $kind) as $deleted) {
|
||
GlobalPreset::remove_preset_from_legacy_option(
|
||
(string) ($deleted['id'] ?? ''),
|
||
(string) ($deleted['moduleName'] ?? '')
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
$saved = GlobalPreset::save_data($prepared);
|
||
|
||
return is_array($saved) ? $saved : [];
|
||
}
|
||
|
||
/**
|
||
* The parts of $attrs a preset of $moduleName carries (Divi's preset attribute mapping): all, style
|
||
* (styleAttrs) and render (renderAttrs: html + script).
|
||
*
|
||
* @param array<string, mixed> $attrs
|
||
* @return array{all: array<string, mixed>, style: array<string, mixed>, render: array<string, mixed>}
|
||
*/
|
||
public function presetAttrs(string $moduleName, array $attrs): array
|
||
{
|
||
$map = Conversion::get_preset_attrs_mapping($moduleName);
|
||
|
||
return [
|
||
'all' => (array) Conversion::get_preset_attrs($attrs, self::PRESET_TYPES, $map),
|
||
'style' => (array) Conversion::get_preset_attrs($attrs, ['style'], $map),
|
||
'render' => (array) Conversion::get_preset_attrs($attrs, ['html', 'script'], $map),
|
||
];
|
||
}
|
||
|
||
/** Divi's role right "Visual Builder verwenden"; fail closed without Divi. */
|
||
public function canUseVisualBuilder(): bool
|
||
{
|
||
return class_exists(UserRole::class) && UserRole::can_current_user_use_visual_builder();
|
||
}
|
||
}
|
||
```
|
||
|
||
In `src/Integrations/Divi/Release/DiviAdapters.php` `DesignSystemWriter::class` direkt nach `DesignSystemReader::class` in `CLASSES` aufnehmen und importieren (`use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;`).
|
||
|
||
- [ ] **Step 4: Unit-Tests laufen lassen**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php tests/Unit/Integrations/Divi/Release`
|
||
Expected: PASS bis auf Tests, die den Fingerabdruck jedes Mirrors in `fingerprints.json` erwarten (z. B. „Mirror ohne Soll-Fingerabdruck“) – die werden in Step 5 grün.
|
||
|
||
- [ ] **Step 5: Fingerabdruck und Referenz erzeugen**
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress
|
||
ddev wp --user=admin lit-mcp divi-check --update-reference
|
||
cd wp-content/plugins/WP-LIT-MCP && git diff --stat src/Integrations/Divi/Release/fingerprints.json && ls tests/Fixtures/divi/reference/preset-sync.txt
|
||
```
|
||
Expected: `fingerprints.json` bekommt genau den neuen Schlüssel `preset-sync` (die übrigen Werte bleiben); `tests/Fixtures/divi/reference/preset-sync.txt` existiert und enthält den normalisierten Quelltext von `GlobalPresetController::sync`. Ändern sich andere Schlüssel, nicht committen, sondern prüfen (installiertes Divi ≠ freigegebene Version).
|
||
|
||
- [ ] **Step 6: Gesamtlauf, Lint, Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK; `DiviBoundaryTest` grün (alle Divi-Namen des Writers stehen im Manifest).
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DesignSystemWriter.php src/Integrations/Divi/Release/DiviAdapters.php src/Integrations/Divi/Release/fingerprints.json tests/Fixtures/divi/reference/preset-sync.txt tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php
|
||
git commit -m "feat: DesignSystemWriter – Presets über Divis Sync-Ablauf schreiben (Mirror preset-sync)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: `GlobalUsages` mit austauschbarer Suche (`UsageQuery`, `LibraryUsageQuery`)
|
||
|
||
Refaktorierung ohne Verhaltensänderung: Rechte, Grenzen und Bericht von `GlobalUsages` werden für beliebige Verweise nutzbar; die bisherige Suche nach Bibliotheksverweisen wird `LibraryUsageQuery`.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Library/UsageQuery.php`
|
||
- Create: `src/Integrations/Divi/Library/LibraryUsageQuery.php`
|
||
- Modify: `src/Integrations/Divi/Library/GlobalUsages.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `UsageMatcher::shows()` (vorhanden).
|
||
- Produces:
|
||
- `interface UsageQuery { public function candidateCondition(\wpdb $db): string; public function matches(string $content): bool; }` – `candidateCondition()` liefert eine **fertig vorbereitete** SQL-Bedingung (mit `$db->prepare()`), die Kandidaten über `post_content` vorfiltert; `matches()` entscheidet am Inhalt (fail closed).
|
||
- `final class LibraryUsageQuery implements UsageQuery { public function __construct(int $libraryId) }`.
|
||
- `GlobalUsages::reportFor(UsageQuery $query, bool $withBlocked = true): UsageReport` und `GlobalUsages::rowsFor(UsageQuery $query): list<array{id: int, status: string, type: string}>`; `report(int, bool)` und `rows(int)` bleiben und delegieren an `new LibraryUsageQuery($id)`.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Integrations\Divi\Library\LibraryUsageQuery;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class LibraryUsageQueryTest extends TestCase
|
||
{
|
||
public function testConditionIsTheFormerCandidateFilter(): void
|
||
{
|
||
$db = Mockery::mock(\wpdb::class);
|
||
$db->allows('esc_like')->andReturnUsing(static fn (string $text): string => $text);
|
||
$db->expects('prepare')
|
||
->with(
|
||
'(LOWER(post_content) LIKE %s OR LOWER(post_content) LIKE %s OR post_content REGEXP %s)',
|
||
'%globalmodule%',
|
||
'%global_module%',
|
||
'\\\\u00[4-7][0-9a-fA-F]'
|
||
)
|
||
->andReturn('(prepared)');
|
||
|
||
self::assertSame('(prepared)', (new LibraryUsageQuery(12))->candidateCondition($db));
|
||
}
|
||
|
||
public function testMatchesThroughTheUsageMatcher(): void
|
||
{
|
||
$query = new LibraryUsageQuery(12);
|
||
|
||
self::assertTrue($query->matches('<!-- wp:divi/section {"globalModule":"12"} /-->'));
|
||
self::assertFalse($query->matches('<!-- wp:divi/section {"globalModule":"13"} /-->'));
|
||
}
|
||
}
|
||
```
|
||
|
||
In `tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php` ergänzen (Aufbau der vorhandenen `$wpdb`-Mocks des Tests übernehmen; der Test erwartet, dass die Kandidatenabfrage die Bedingung der Suche enthält):
|
||
|
||
```php
|
||
public function testRowsForUsesTheConditionAndMatcherOfTheQuery(): void
|
||
{
|
||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||
$queries = [];
|
||
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array {
|
||
$queries[] = $sql;
|
||
|
||
return str_contains($sql, 'post_content FROM')
|
||
? [['ID' => '5', 'post_content' => 'treffer'], ['ID' => '6', 'post_content' => 'nichts']]
|
||
: [['ID' => '5', 'post_status' => 'draft', 'post_type' => 'page'],
|
||
['ID' => '6', 'post_status' => 'draft', 'post_type' => 'page']];
|
||
});
|
||
$query = new class implements \LIT\MCP\Integrations\Divi\Library\UsageQuery {
|
||
public function candidateCondition(\wpdb $db): string
|
||
{
|
||
return '(MARKER)';
|
||
}
|
||
|
||
public function matches(string $content): bool
|
||
{
|
||
return $content === 'treffer';
|
||
}
|
||
};
|
||
|
||
$rows = (new GlobalUsages($db))->rowsFor($query);
|
||
|
||
self::assertSame([['id' => 5, 'status' => 'draft', 'type' => 'page']], $rows);
|
||
self::assertStringContainsString('WHERE (MARKER) AND post_type', $queries[0]);
|
||
}
|
||
```
|
||
|
||
Der bestehende `testFindsEveryFormatDiviRendersFromCandidates` bleibt unverändert und muss grün bleiben: `LibraryUsageQuery` erzeugt mit dem echten `prepare()` des `wpdb`-Stubs wörtlich dieselbe Kandidaten-SQL wie bisher.
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – sie scheitern**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php`
|
||
Expected: FAIL (`LibraryUsageQuery`/`UsageQuery` fehlen, `rowsFor()` unbekannt).
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`src/Integrations/Divi/Library/UsageQuery.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* What GlobalUsages looks for (ruling P23 pattern, fail closed): a prepared SQL condition on post_content that
|
||
* keeps every post that may reference the thing (one query, ID/status/type only), and the decision on the
|
||
* content of each candidate. A library entry (LibraryUsageQuery), a preset (PresetUsageQuery, Etappe 4) …
|
||
*/
|
||
interface UsageQuery
|
||
{
|
||
/** A condition for "WHERE (…)", already passed through $db->prepare(). */
|
||
public function candidateCondition(\wpdb $db): string;
|
||
|
||
/** Whether $content references the thing; true when unsure (fail closed). */
|
||
public function matches(string $content): bool;
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Divi/Library/LibraryUsageQuery.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The usages of library entry $libraryId (rulings P23, P24): candidates with "globalModule", a Divi 4
|
||
* "global_module" (both case-insensitive, LOWER() under any collation) or a JSON escape of a letter (an escaped
|
||
* key such as globalModule always has one); the decision is UsageMatcher's.
|
||
*/
|
||
final class LibraryUsageQuery implements UsageQuery
|
||
{
|
||
public function __construct(private int $libraryId)
|
||
{
|
||
}
|
||
|
||
public function candidateCondition(\wpdb $db): string
|
||
{
|
||
return (string) $db->prepare(
|
||
'(LOWER(post_content) LIKE %s OR LOWER(post_content) LIKE %s OR post_content REGEXP %s)',
|
||
'%' . $db->esc_like('globalmodule') . '%',
|
||
'%' . $db->esc_like('global_module') . '%',
|
||
str_repeat(chr(92), 2) . 'u00[4-7][0-9a-fA-F]'
|
||
);
|
||
}
|
||
|
||
public function matches(string $content): bool
|
||
{
|
||
return UsageMatcher::shows($content, $this->libraryId);
|
||
}
|
||
}
|
||
```
|
||
|
||
In `src/Integrations/Divi/Library/GlobalUsages.php`:
|
||
|
||
1. `report()`, `rows()` und die neuen Methoden – `report()` ruft weiter `rows()` auf, damit bestehende Tests, die `rows()` überschreiben, wirksam bleiben:
|
||
|
||
```php
|
||
/**
|
||
* The usages of $libraryId with the caller's rights; $withBlocked adds the P8a check (edit_post
|
||
* on every live post) – the dry run and the confirm of an edit need it, a listing does not.
|
||
*/
|
||
public function report(int $libraryId, bool $withBlocked = true): UsageReport
|
||
{
|
||
return $this->reportOfRows($this->rows($libraryId), $withBlocked);
|
||
}
|
||
|
||
/** report() for any reference – a library entry, a preset (Etappe 4) – described by $query. */
|
||
public function reportFor(UsageQuery $query, bool $withBlocked = true): UsageReport
|
||
{
|
||
return $this->reportOfRows($this->rowsFor($query), $withBlocked);
|
||
}
|
||
|
||
/** @return list<array{id: int, status: string, type: string}> */
|
||
public function rows(int $libraryId): array
|
||
{
|
||
return $this->rowsFor(new LibraryUsageQuery($libraryId));
|
||
}
|
||
|
||
/**
|
||
* Every post (no revision, not trashed) whose content $query matches (fail closed). Candidates come from one
|
||
* query (ID, status and type only); their content is read in chunks of CONTENT_CHUNK; beyond PARSE_CAP
|
||
* candidates the rest counts as references unread (audit L1).
|
||
*
|
||
* @return list<array{id: int, status: string, type: string}>
|
||
*/
|
||
public function rowsFor(UsageQuery $query): array
|
||
{
|
||
$candidates = $this->candidates($query);
|
||
$result = [];
|
||
foreach (array_chunk(array_slice($candidates, 0, self::PARSE_CAP), self::CONTENT_CHUNK) as $chunk) {
|
||
$contents = $this->contents(array_map(static fn (array $row): int => $row['id'], $chunk));
|
||
foreach ($chunk as $row) {
|
||
if (! isset($contents[$row['id']]) || $query->matches($contents[$row['id']])) {
|
||
$result[] = $row;
|
||
}
|
||
}
|
||
}
|
||
|
||
return array_merge($result, array_slice($candidates, self::PARSE_CAP));
|
||
}
|
||
```
|
||
|
||
2. Der bisherige Rumpf von `report()` ab der Zeile `$live = count(array_filter($rows, …));` bis zum `return new UsageReport(…);` wird **wörtlich** in die neue private Methode verschoben:
|
||
|
||
```php
|
||
/** @param list<array{id: int, status: string, type: string}> $rows */
|
||
private function reportOfRows(array $rows, bool $withBlocked): UsageReport
|
||
{
|
||
$live = count(array_filter($rows, static fn (array $row): bool => in_array($row['status'], self::LIVE, true)));
|
||
// … ab hier die bisherigen Zeilen von report() bis einschließlich "return new UsageReport(…);" …
|
||
}
|
||
```
|
||
|
||
3. `candidates()` nimmt die Suche:
|
||
|
||
```php
|
||
/** @return list<array{id: int, status: string, type: string}> */
|
||
private function candidates(UsageQuery $query): array
|
||
{
|
||
$db = $this->db();
|
||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
|
||
$rows = $db->get_results(
|
||
'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE ' . $query->candidateCondition($db)
|
||
. " AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC',
|
||
'ARRAY_A'
|
||
);
|
||
// phpcs:enable
|
||
// … unveränderte Umwandlung in $result …
|
||
}
|
||
```
|
||
|
||
4. Klassen-Docblock: erster Satz „Posts (pages, Theme Builder layouts, other library entries) that reference something a UsageQuery describes – a library entry $id (LibraryUsageQuery) or, since Etappe 4, a preset: …“; der Rest bleibt.
|
||
|
||
Alle übrigen Erwartungen von `GlobalUsagesTest` (Kandidaten-SQL, Rechte, Grenzen, Theme Builder) bleiben unverändert.
|
||
|
||
- [ ] **Step 4: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Library`
|
||
Expected: PASS, alle Bibliotheks-Tests grün.
|
||
|
||
- [ ] **Step 5: Gesamtlauf, Lint, Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Library/UsageQuery.php src/Integrations/Divi/Library/LibraryUsageQuery.php src/Integrations/Divi/Library/GlobalUsages.php tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php
|
||
git commit -m "refactor: GlobalUsages sucht über UsageQuery – Bibliothekssuche als LibraryUsageQuery" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Fundstellen eines Presets – `PresetUsageMatcher`, `PresetUsageQuery`
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/PresetUsageMatcher.php`
|
||
- Create: `src/Integrations/Divi/Design/PresetUsageQuery.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Parser::parse()`, `MalformedMarkup`, `NodeIndex::build()`, `NodeIndex::entries()`, `NodeSummary::presetIds()` (vorhanden), `PresetScope` (Task 2), `UsageQuery` (Task 6).
|
||
- Produces:
|
||
- `final class PresetUsageMatcher { public function __construct(private Parser $parser = new Parser()); public function uses(string $content, PresetScope $scope, string $id, bool $isDefault): bool }` – Modul-Preset: ein Block von `$scope->name` mit `$id` in `modulePreset` oder – wenn `$isDefault` – ohne eigenes Modul-Preset (bzw. `""`/`default`/`_initial`); Gruppen-Preset: ein Block mit `$id` in einem `groupPreset.*.presetId` mit `groupName` `$scope->name` oder – wenn `$isDefault` – ein Block mit dieser Gruppe ohne eigenes Gruppen-Preset ist **nicht** Teil von 4a (siehe Hinweis); fail closed bei kaputtem Markup.
|
||
- `final class PresetUsageQuery implements UsageQuery { public function __construct(array $targets, PresetUsageMatcher $matcher = new PresetUsageMatcher()) }` – `$targets`: `list<array{scope: PresetScope, id: string, default: bool}>`, nicht leer; ein Beitrag zählt, wenn er **eines** der Presets verwendet (ein Aufruf von `lit/divi-presets-set` kann mehrere Presets ändern).
|
||
|
||
Hinweis: Welche Module ein **Standard-Gruppen-Preset** erreicht, hängt von Divis Gruppenliste je Modul ab (`get_group_preset_default_attr`, Formate §3) – das kommt mit der Gruppen-Zuweisung in 4b. In 4a meldet der Probelauf für ein Standard-Gruppen-Preset nur die Seiten mit expliziter Zuweisung und warnt: „Standard-Gruppen-Presets wirken zusätzlich auf alle Module mit dieser Gruppe; diese Seiten sind nicht aufgelistet.“
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetUsageMatcher;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class PresetUsageMatcherTest extends TestCase
|
||
{
|
||
private const BUTTON = '<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||
. '<!-- wp:divi/button %s /--><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->';
|
||
|
||
private static function page(string $buttonAttrs): string
|
||
{
|
||
return sprintf(self::BUTTON, $buttonAttrs);
|
||
}
|
||
|
||
public function testModulePresetInTheStack(): void
|
||
{
|
||
$matcher = new PresetUsageMatcher();
|
||
$button = new PresetScope('module', 'divi/button');
|
||
|
||
self::assertTrue($matcher->uses(self::page('{"modulePreset":["aaaaaaaaaa"]}'), $button, 'aaaaaaaaaa', false));
|
||
self::assertTrue($matcher->uses(self::page('{"modulePreset":["x","aaaaaaaaaa"]}'), $button, 'aaaaaaaaaa', false));
|
||
self::assertTrue($matcher->uses(self::page('{"modulePreset":"aaaaaaaaaa"}'), $button, 'aaaaaaaaaa', false));
|
||
self::assertFalse($matcher->uses(self::page('{"modulePreset":["bbbbbbbbbb"]}'), $button, 'aaaaaaaaaa', false));
|
||
self::assertFalse(
|
||
$matcher->uses(self::page('{"modulePreset":["aaaaaaaaaa"]}'), new PresetScope('module', 'divi/text'), 'aaaaaaaaaa', false),
|
||
'an ID of another module does not count'
|
||
);
|
||
}
|
||
|
||
public function testDefaultModulePresetReachesModulesWithoutOwnPreset(): void
|
||
{
|
||
$matcher = new PresetUsageMatcher();
|
||
$button = new PresetScope('module', 'divi/button');
|
||
|
||
self::assertTrue($matcher->uses(self::page('{}'), $button, 'dddddddddd', true));
|
||
self::assertTrue($matcher->uses(self::page('{"modulePreset":["default"]}'), $button, 'dddddddddd', true));
|
||
self::assertFalse($matcher->uses(self::page('{"modulePreset":["aaaaaaaaaa"]}'), $button, 'dddddddddd', true));
|
||
}
|
||
|
||
public function testGroupPresetById(): void
|
||
{
|
||
$matcher = new PresetUsageMatcher();
|
||
$group = new PresetScope('group', 'divi/font-body');
|
||
$attrs = '{"groupPreset":{"content.decoration.bodyFont":{"presetId":["gggggggggg"],"groupName":"divi/font-body"}}}';
|
||
|
||
self::assertTrue($matcher->uses(self::page($attrs), $group, 'gggggggggg', false));
|
||
self::assertFalse($matcher->uses(self::page($attrs), new PresetScope('group', 'divi/spacing'), 'gggggggggg', false));
|
||
}
|
||
|
||
public function testBrokenMarkupCountsAsUse(): void
|
||
{
|
||
self::assertTrue((new PresetUsageMatcher())->uses(
|
||
'<!-- wp:divi/section {"a": --><!-- /wp:divi/row -->',
|
||
new PresetScope('module', 'divi/button'),
|
||
'aaaaaaaaaa',
|
||
false
|
||
));
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class PresetUsageQueryTest extends TestCase
|
||
{
|
||
public function testCandidatesContainAnIdOrAJsonEscape(): void
|
||
{
|
||
$db = Mockery::mock(\wpdb::class);
|
||
$db->allows('esc_like')->andReturnUsing(static fn (string $text): string => $text);
|
||
$db->expects('prepare')
|
||
->with(
|
||
'(post_content LIKE %s OR post_content LIKE %s OR post_content REGEXP %s)',
|
||
'%aaaaaaaaaa%',
|
||
'%gggggggggg%',
|
||
'\\\\u00[4-7][0-9a-fA-F]'
|
||
)
|
||
->andReturn('(prepared)');
|
||
|
||
$query = new PresetUsageQuery([
|
||
['scope' => new PresetScope('module', 'divi/button'), 'id' => 'aaaaaaaaaa', 'default' => false],
|
||
['scope' => new PresetScope('group', 'divi/font-body'), 'id' => 'gggggggggg', 'default' => false],
|
||
]);
|
||
|
||
self::assertSame('(prepared)', $query->candidateCondition($db));
|
||
self::assertTrue($query->matches('<!-- wp:divi/text {"groupPreset":{"content.decoration.bodyFont":'
|
||
. '{"presetId":["gggggggggg"],"groupName":"divi/font-body"}}} /-->'));
|
||
self::assertFalse($query->matches('<!-- wp:divi/text {} /-->'));
|
||
}
|
||
|
||
public function testADefaultModulePresetLooksForTheModule(): void
|
||
{
|
||
$db = Mockery::mock(\wpdb::class);
|
||
$db->allows('esc_like')->andReturnUsing(static fn (string $text): string => $text);
|
||
$db->expects('prepare')
|
||
->with(
|
||
'(post_content LIKE %s OR post_content LIKE %s OR post_content REGEXP %s)',
|
||
'%dddddddddd%',
|
||
'%wp:divi/button %',
|
||
'\\\\u00[4-7][0-9a-fA-F]'
|
||
)
|
||
->andReturn('(prepared)');
|
||
|
||
$query = new PresetUsageQuery([
|
||
['scope' => new PresetScope('module', 'divi/button'), 'id' => 'dddddddddd', 'default' => true],
|
||
]);
|
||
|
||
self::assertSame('(prepared)', $query->candidateCondition($db));
|
||
self::assertTrue($query->matches('<!-- wp:divi/button {} /-->'));
|
||
}
|
||
|
||
public function testNeedsATarget(): void
|
||
{
|
||
$this->expectException(\InvalidArgumentException::class);
|
||
|
||
new PresetUsageQuery([]);
|
||
}
|
||
}
|
||
```
|
||
|
||
Warum die JSON-Escape-Bedingung: Divi kann Schlüssel und Werte in Blockattributen mit `\u00XX` schreiben; eine so kodierte ID fände `LIKE` nicht (fail closed wie Ruling P23). Kandidaten ohne Treffer filtert `matches()` heraus.
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – sie scheitern**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php`
|
||
Expected: FAIL, Klassen fehlen.
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`src/Integrations/Divi/Design/PresetUsageMatcher.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Whether post content uses a preset (spec §6.4, formats §3 "Verweise in Blöcken"), fail closed:
|
||
* - module preset $id: a block of the module with $id in its modulePreset stack (array or legacy string);
|
||
* the default preset additionally reaches every block of the module without an own preset ("" / "default" /
|
||
* "_initial" mean the default, GlobalPreset::is_preset_id_as_default);
|
||
* - group preset $id: a block with $id in groupPreset.<attr>.presetId whose groupName is the group;
|
||
* - markup the parser rejects counts as a use.
|
||
* Pure PHP.
|
||
*/
|
||
final class PresetUsageMatcher
|
||
{
|
||
private const DEFAULT_IDS = ['', 'default', '_initial'];
|
||
|
||
public function __construct(private Parser $parser = new Parser())
|
||
{
|
||
}
|
||
|
||
public function uses(string $content, PresetScope $scope, string $id, bool $isDefault): bool
|
||
{
|
||
try {
|
||
$index = NodeIndex::build($this->parser->parse($content));
|
||
} catch (MalformedMarkup) {
|
||
return true;
|
||
}
|
||
|
||
foreach ($index->entries() as $entry) {
|
||
$block = $entry['block'];
|
||
if ($scope->kind === PresetScope::MODULE ? $this->usesModulePreset($block, $scope, $id, $isDefault)
|
||
: $this->usesGroupPreset($block->attrs(), $scope, $id)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function usesModulePreset(Block $block, PresetScope $scope, string $id, bool $isDefault): bool
|
||
{
|
||
if ($block->name() !== $scope->name) {
|
||
return false;
|
||
}
|
||
|
||
$stack = NodeSummary::presetIds($block);
|
||
if (in_array($id, $stack, true)) {
|
||
return true;
|
||
}
|
||
|
||
return $isDefault && array_diff($stack, self::DEFAULT_IDS) === [];
|
||
}
|
||
|
||
/** @param array<string, mixed> $attrs */
|
||
private function usesGroupPreset(array $attrs, PresetScope $scope, string $id): bool
|
||
{
|
||
$groups = is_array($attrs['groupPreset'] ?? null) ? $attrs['groupPreset'] : [];
|
||
foreach ($groups as $group) {
|
||
if (! is_array($group) || ($group['groupName'] ?? null) !== $scope->name) {
|
||
continue;
|
||
}
|
||
if (in_array($id, (array) ($group['presetId'] ?? []), true)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
`NodeSummary::presetIds()` filtert leere Strings heraus; ein `modulePreset: [""]` ergibt also `[]` und zählt für das Standard-Preset – wie in Divi.
|
||
|
||
`src/Integrations/Divi/Design/PresetUsageQuery.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Library\UsageQuery;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The posts that use any of the presets $targets (GlobalUsages::reportFor()): candidates contain one of the IDs, a
|
||
* JSON escape of a letter (an escaped ID or key, fail closed like ruling P23) or – for a default module preset – a
|
||
* block of the module; PresetUsageMatcher decides per target.
|
||
*/
|
||
final class PresetUsageQuery implements UsageQuery
|
||
{
|
||
private const ESCAPED_LETTER = '\\\\u00[4-7][0-9a-fA-F]';
|
||
|
||
/** @var list<array{scope: PresetScope, id: string, default: bool}> */
|
||
private array $targets;
|
||
|
||
/**
|
||
* @param list<array{scope: PresetScope, id: string, default: bool}> $targets
|
||
* @throws InvalidArgumentException Without targets.
|
||
*/
|
||
public function __construct(array $targets, private PresetUsageMatcher $matcher = new PresetUsageMatcher())
|
||
{
|
||
if ($targets === []) {
|
||
throw new InvalidArgumentException('PresetUsageQuery needs at least one preset.');
|
||
}
|
||
$this->targets = array_values($targets);
|
||
}
|
||
|
||
public function candidateCondition(\wpdb $db): string
|
||
{
|
||
$likes = [];
|
||
foreach ($this->targets as $target) {
|
||
$likes[] = '%' . $db->esc_like($target['id']) . '%';
|
||
if ($target['default'] && $target['scope']->kind === PresetScope::MODULE) {
|
||
$likes[] = '%' . $db->esc_like('wp:' . $target['scope']->name . ' ') . '%';
|
||
}
|
||
}
|
||
$likes = array_values(array_unique($likes));
|
||
|
||
return (string) $db->prepare(
|
||
'(' . str_repeat('post_content LIKE %s OR ', count($likes)) . 'post_content REGEXP %s)',
|
||
...array_merge($likes, [self::ESCAPED_LETTER])
|
||
);
|
||
}
|
||
|
||
public function matches(string $content): bool
|
||
{
|
||
foreach ($this->targets as $target) {
|
||
if ($this->matcher->uses($content, $target['scope'], $target['id'], $target['default'])) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
`'\\\\u00[4-7][0-9a-fA-F]'` in einfachen Anführungszeichen ergibt die Zeichenfolge `\\u00[4-7][0-9a-fA-F]` – dieselbe wie `str_repeat(chr(92), 2) . 'u00…'` in `LibraryUsageQuery` (MySQL-REGEXP sieht nach dem Entschärfen `\u00…`).
|
||
|
||
Ein Modul ohne Attribute steht als `<!-- wp:divi/button /-->` im Inhalt (ohne Leerzeichen-JSON); die Kandidatenbedingung `wp:divi/button ` (mit Leerzeichen) trifft beide Schreibweisen, weil auf den Namen immer ein Leerzeichen folgt.
|
||
|
||
- [ ] **Step 4: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Gesamtlauf, Lint, Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/PresetUsageMatcher.php src/Integrations/Divi/Design/PresetUsageQuery.php tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php
|
||
git commit -m "feat: Fundstellen eines Presets (PresetUsageMatcher, PresetUsageQuery)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
### Task 8: Änderungs-Pipeline – `DesignArea`, `DesignPlan`, `DesignChange`, `PresetArea`
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/DesignArea.php`
|
||
- Create: `src/Integrations/Divi/Design/DesignPlan.php`
|
||
- Create: `src/Integrations/Divi/Design/DesignChange.php`
|
||
- Create: `src/Integrations/Divi/Design/PresetArea.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/DesignChangeTest.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/PresetAreaTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SnapshotStore`, `SnapshotTooLarge` (Task 4), `DesignSystemWriter::savePresets()`/`presets()` (Task 5), `PresetBook`, `PresetDiff` (Tasks 2–3), `UsageReport` (vorhanden), `ChangeLog::record()/recordPartial()`, `Errors`, `ToolResult`, `Rollback`, `WriteFailed`, `ReleaseGate::assertWritable()`, `DiviLocked::in()`.
|
||
- Produces:
|
||
- `interface DesignArea { public function name(): string; public function label(): string; public function raw(): array; public function rev(array $raw): string; public function write(array $raw, bool $mayRemove): array; public function verify(array $expected, array $actual): bool; }`
|
||
- `final class DesignPlan { public function __construct(public readonly array $raw, public readonly array $diff, public readonly string $summary, public readonly ?UsageReport $usages = null, public readonly array $warnings = [], public readonly bool $mayRemove = false); public function isEmpty(): bool }`
|
||
- `final class DesignChange { public function __construct(SnapshotStore $snapshots, ChangeLog $log); public function run(string $ability, DesignArea $area, array $input, callable $plan): array|WP_Error; public static function inputProperties(): array; public static function outputSchema(): array }` – `$plan` ist `callable(array $currentRaw): DesignPlan|WP_Error`.
|
||
- `final class PresetArea implements DesignArea { public function __construct(DesignSystemWriter $writer) }`.
|
||
|
||
Antworten von `run()`:
|
||
- Plan liefert `WP_Error` → unverändert zurück.
|
||
- Leerer Plan → `ToolResult` „Keine Änderung …“ mit `diff: []`, `design_rev`.
|
||
- Ohne `confirm: true` → `Errors::confirmRequired($summary . ' Nichts wurde gespeichert (Probelauf).', ['diff' => …, 'design_rev' => …] + affected-Liste + ggf. `blocked_ids`)`.
|
||
- `confirm` ohne/mit falscher `design_rev` → `Errors::revMismatch(<Bereich> wurde seit dem Probelauf geändert …)`.
|
||
- Fundstellen blockieren (Live-Seite ohne `edit_post`, über `HARD_CAP`) → `forbidden` mit `blocked_ids`.
|
||
- Schreiben → `ToolResult` mit `snapshot_id`, `log_id`, `diff`, `design_rev` (nach dem Schreiben), `warnings`.
|
||
|
||
- [ ] **Step 1: Failing test für `DesignChange` schreiben**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\DesignArea;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotTooLarge;
|
||
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use RuntimeException;
|
||
|
||
final class DesignChangeTest extends TestCase
|
||
{
|
||
/** A DesignArea over an array; $failWrite/$failRestore make write()/the rollback throw. */
|
||
private static function area(array &$stored, int $failWrites = 0, bool $verify = true): DesignArea
|
||
{
|
||
return new class ($stored, $failWrites, $verify) implements DesignArea {
|
||
public array $writes = [];
|
||
|
||
public function __construct(private array &$stored, private int $failWrites, private bool $verify)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'presets';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Presets';
|
||
}
|
||
|
||
public function raw(): array
|
||
{
|
||
return $this->stored;
|
||
}
|
||
|
||
public function rev(array $raw): string
|
||
{
|
||
return substr(md5((string) json_encode($raw)), 0, 12);
|
||
}
|
||
|
||
public function write(array $raw, bool $mayRemove): array
|
||
{
|
||
$this->writes[] = [$raw, $mayRemove];
|
||
if ($this->failWrites > 0) {
|
||
--$this->failWrites;
|
||
throw new RuntimeException('divi refused');
|
||
}
|
||
$this->stored = $raw;
|
||
|
||
return $raw;
|
||
}
|
||
|
||
public function verify(array $expected, array $actual): bool
|
||
{
|
||
return $this->verify;
|
||
}
|
||
};
|
||
}
|
||
|
||
private static function plan(array $raw, ?UsageReport $usages = null): callable
|
||
{
|
||
return static fn (array $current): DesignPlan => new DesignPlan(
|
||
$raw,
|
||
[['kind' => 'module', 'scope' => 'divi/button', 'id' => 'a', 'name' => 'CTA', 'action' => 'added']],
|
||
'Preset „CTA“ wird angelegt.',
|
||
$usages
|
||
);
|
||
}
|
||
|
||
private static function usages(array $blocked = []): UsageReport
|
||
{
|
||
return new UsageReport(1, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']],
|
||
0, 1, $blocked, 0, false);
|
||
}
|
||
|
||
public function testDryRunListsDiffAffectedAndRevAndSavesNothing(): void
|
||
{
|
||
$stored = ['v' => 1];
|
||
$area = self::area($stored);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->shouldNotReceive('save');
|
||
|
||
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))
|
||
->run('lit/divi-presets-set', $area, [], self::plan(['v' => 2], self::usages()));
|
||
|
||
$details = self::assertToolError($result, 'confirm_required');
|
||
self::assertStringContainsString('Preset „CTA“ wird angelegt. Nichts wurde gespeichert (Probelauf).', $result->get_error_message());
|
||
self::assertSame('added', $details['diff'][0]['action']);
|
||
self::assertSame($area->rev(['v' => 1]), $details['design_rev']);
|
||
self::assertSame(3, $details['affected'][0]['post_id']);
|
||
self::assertSame(1, $details['affected_count']);
|
||
self::assertSame([], $area->writes);
|
||
}
|
||
|
||
public function testNothingToDoIsNoDryRun(): void
|
||
{
|
||
$stored = ['v' => 1];
|
||
$empty = static fn (array $current): DesignPlan => new DesignPlan($current, [], 'Nichts.');
|
||
|
||
$result = (new DesignChange(Mockery::mock(SnapshotStore::class), FakeChangeLog::forbidden()))
|
||
->run('lit/divi-presets-set', self::area($stored), [], $empty);
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame([], $result['diff']);
|
||
self::assertStringStartsWith('Keine Änderung', $result['summary']);
|
||
}
|
||
|
||
public function testStaleDesignRevWritesNothing(): void
|
||
{
|
||
$stored = ['v' => 1];
|
||
$area = self::area($stored);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->shouldNotReceive('save');
|
||
|
||
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))
|
||
->run('lit/divi-presets-set', $area, ['confirm' => true, 'design_rev' => 'veraltet'], self::plan(['v' => 2]));
|
||
|
||
self::assertToolError($result, 'rev_mismatch');
|
||
self::assertStringContainsString('Presets wurden seit dem Probelauf geändert', $result->get_error_message());
|
||
self::assertSame([], $area->writes);
|
||
}
|
||
|
||
public function testBlockedUsagesRefuseTheConfirm(): void
|
||
{
|
||
$stored = ['v' => 1];
|
||
$area = self::area($stored);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->shouldNotReceive('save');
|
||
|
||
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
||
'lit/divi-presets-set',
|
||
$area,
|
||
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
||
self::plan(['v' => 2], self::usages([3]))
|
||
);
|
||
|
||
$details = self::assertToolError($result, 'forbidden');
|
||
self::assertSame([3], $details['blocked_ids']);
|
||
}
|
||
|
||
public function testConfirmTakesASnapshotWritesAndLogs(): void
|
||
{
|
||
$stored = ['v' => 1];
|
||
$area = self::area($stored);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->expects('save')->with('presets', ['v' => 1], 'lit/divi-presets-set')->andReturn('presets-1');
|
||
$snapshots->expects('attachLog')->with('presets', 'presets-1', 9);
|
||
$log = new FakeChangeLog(9);
|
||
|
||
$result = (new DesignChange($snapshots, $log))->run(
|
||
'lit/divi-presets-set',
|
||
$area,
|
||
['confirm' => true, 'design_rev' => $area->rev(['v' => 1])],
|
||
self::plan(['v' => 2])
|
||
);
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame('presets-1', $result['snapshot_id']);
|
||
self::assertSame(9, $result['log_id']);
|
||
self::assertSame($area->rev(['v' => 2]), $result['design_rev']);
|
||
self::assertSame(['v' => 2], $stored);
|
||
self::assertSame('presets', $log->records[0]['object_id']);
|
||
self::assertSame('divi_design', $log->records[0]['object_type']);
|
||
self::assertSame('presets-1', $log->records[0]['snapshot_id']);
|
||
}
|
||
|
||
public function testLockedDiviWritesNoSnapshot(): void
|
||
{
|
||
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.16.0'));
|
||
$stored = ['v' => 1];
|
||
$area = self::area($stored);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->shouldNotReceive('save');
|
||
|
||
$this->expectException(DiviLocked::class);
|
||
|
||
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
||
'lit/divi-presets-set',
|
||
$area,
|
||
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
||
self::plan(['v' => 2])
|
||
);
|
||
}
|
||
|
||
public function testTooLargeSnapshotRefusesTheChange(): void
|
||
{
|
||
$stored = ['v' => 1];
|
||
$area = self::area($stored);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->expects('save')->andThrow(new SnapshotTooLarge(3000000));
|
||
|
||
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
||
'lit/divi-presets-set',
|
||
$area,
|
||
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
||
self::plan(['v' => 2])
|
||
);
|
||
|
||
self::assertToolError($result, 'snapshot_too_large');
|
||
self::assertSame([], $area->writes);
|
||
}
|
||
|
||
public function testFailedWriteRestoresTheSnapshot(): void
|
||
{
|
||
$stored = ['v' => 1];
|
||
$area = self::area($stored, 1);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->expects('save')->andReturn('presets-1');
|
||
|
||
try {
|
||
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
||
'lit/divi-presets-set',
|
||
$area,
|
||
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
||
self::plan(['v' => 2])
|
||
);
|
||
self::fail('expected WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertTrue($e->rolledBack);
|
||
self::assertSame('presets-1', $e->details['snapshot_id']);
|
||
}
|
||
|
||
self::assertSame([[['v' => 2], false], [['v' => 1], true]], $area->writes);
|
||
self::assertSame(['v' => 1], $stored);
|
||
}
|
||
|
||
public function testReadBackMismatchRestoresTheSnapshot(): void
|
||
{
|
||
$stored = ['v' => 1];
|
||
$area = self::area($stored, 0, false);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->expects('save')->andReturn('presets-1');
|
||
|
||
$this->expectException(WriteFailed::class);
|
||
|
||
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
||
'lit/divi-presets-set',
|
||
$area,
|
||
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
||
self::plan(['v' => 2])
|
||
);
|
||
}
|
||
|
||
public function testFailedRollbackIsPartial(): void
|
||
{
|
||
$stored = ['v' => 1];
|
||
$area = self::area($stored, 2);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->expects('save')->andReturn('presets-1');
|
||
$log = FakeChangeLog::partialsOnly();
|
||
|
||
try {
|
||
(new DesignChange($snapshots, $log))->run(
|
||
'lit/divi-presets-set',
|
||
$area,
|
||
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
||
self::plan(['v' => 2])
|
||
);
|
||
self::fail('expected WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertFalse($e->rolledBack);
|
||
self::assertStringContainsString('presets-1', (string) $e->hint);
|
||
}
|
||
|
||
self::assertCount(1, $log->partials);
|
||
}
|
||
}
|
||
```
|
||
|
||
`assertToolError()` (Trait `AssertsToolErrors`) gibt die Fehlerdaten zurück, wie der Client sie sieht (JSON der Meldung inklusive aller Details).
|
||
|
||
- [ ] **Step 2: Test laufen lassen – er scheitert**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DesignChangeTest.php`
|
||
Expected: FAIL, Klassen fehlen.
|
||
|
||
- [ ] **Step 3: `DesignArea` und `DesignPlan` implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* One area of the design system for DesignChange (spec §2, §4): colors, variables, presets or theme options – its
|
||
* raw inventory as Divi stores it (also the snapshot), its design_rev, how it is written through Divi and how a
|
||
* read-back is judged. write() throws on failure; it is also how a snapshot is written back ($mayRemove = true).
|
||
*/
|
||
interface DesignArea
|
||
{
|
||
/** One of SnapshotStore::AREAS. */
|
||
public function name(): string;
|
||
|
||
/** German name for messages, e.g. "Presets". */
|
||
public function label(): string;
|
||
|
||
/** @return array<array-key, mixed> */
|
||
public function raw(): array;
|
||
|
||
/** @param array<array-key, mixed> $raw */
|
||
public function rev(array $raw): string;
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $raw
|
||
* @return array<array-key, mixed> The inventory as read back after writing.
|
||
*/
|
||
public function write(array $raw, bool $mayRemove): array;
|
||
|
||
/**
|
||
* Whether Divi stored what was written – sanitizing may change values, never which entries exist.
|
||
*
|
||
* @param array<array-key, mixed> $expected
|
||
* @param array<array-key, mixed> $actual
|
||
*/
|
||
public function verify(array $expected, array $actual): bool;
|
||
}
|
||
```
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* What a design change would do (spec §4 step 3–4): the new raw inventory, the diff, a one-sentence German summary,
|
||
* the posts it affects (null = none to report), warnings and whether entries are removed (delete/restore).
|
||
*/
|
||
final class DesignPlan
|
||
{
|
||
/**
|
||
* @param array<array-key, mixed> $raw
|
||
* @param list<array<string, mixed>> $diff
|
||
* @param list<string> $warnings
|
||
*/
|
||
public function __construct(
|
||
public readonly array $raw,
|
||
public readonly array $diff,
|
||
public readonly string $summary,
|
||
public readonly ?UsageReport $usages = null,
|
||
public readonly array $warnings = [],
|
||
public readonly bool $mayRemove = false
|
||
) {
|
||
}
|
||
|
||
public function isEmpty(): bool
|
||
{
|
||
return $this->diff === [];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: `DesignChange` implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\Rollback;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The change pipeline of every design system write (spec §4, G2, G6): plan on the live inventory → without
|
||
* confirm a dry run (diff, affected posts, design_rev; nothing saved) → with confirm the design_rev must still match
|
||
* → no live post may block (P8/P8a) → release lock → snapshot of the area → write through Divi → read back and
|
||
* verify → change log. A failed write or read-back writes the snapshot back (Rollback); when that fails too the
|
||
* error is partial_write with the snapshot to restore by hand. The rights are the tool's (spec §10).
|
||
*/
|
||
final class DesignChange
|
||
{
|
||
public const OBJECT_TYPE = 'divi_design';
|
||
|
||
private const LISTED_IDS = 10;
|
||
|
||
public function __construct(private SnapshotStore $snapshots, private ChangeLog $log)
|
||
{
|
||
}
|
||
|
||
/** @return array<string, array<string, mixed>> Input properties every design write tool takes. */
|
||
public static function inputProperties(): array
|
||
{
|
||
return [
|
||
'confirm' => ['type' => 'boolean', 'description' => 'Erst nach dem Probelauf und der Rückfrage beim Nutzer.'],
|
||
'design_rev' => ['type' => 'string', 'description' => 'design_rev aus dem Probelauf (Pflicht mit confirm).'],
|
||
];
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public static function outputSchema(): array
|
||
{
|
||
return ToolResult::schema([
|
||
'diff' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'design_rev' => ['type' => 'string'],
|
||
], ['diff', 'design_rev']);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input The tool input (confirm, design_rev).
|
||
* @param callable(array<array-key, mixed>): (DesignPlan|WP_Error) $plan
|
||
* @return array<string, mixed>|WP_Error
|
||
* @throws WriteFailed
|
||
* @throws DiviLocked
|
||
*/
|
||
public function run(string $ability, DesignArea $area, array $input, callable $plan): array|WP_Error
|
||
{
|
||
$current = $area->raw();
|
||
$rev = $area->rev($current);
|
||
$planned = $plan($current);
|
||
if ($planned instanceof WP_Error) {
|
||
return $planned;
|
||
}
|
||
|
||
if ($planned->isEmpty()) {
|
||
return ToolResult::ok('Keine Änderung – ' . $planned->summary)
|
||
->with('diff', [])
|
||
->with('design_rev', $rev)
|
||
->warnings($planned->warnings)
|
||
->toArray();
|
||
}
|
||
|
||
if (($input['confirm'] ?? false) !== true) {
|
||
return Errors::confirmRequired(
|
||
$planned->summary . ' Nichts wurde gespeichert (Probelauf).',
|
||
['diff' => $planned->diff, 'design_rev' => $rev, 'warnings' => $planned->warnings]
|
||
+ ($planned->usages?->listing() ?? ['affected' => [], 'affected_count' => 0])
|
||
+ ($planned->usages !== null && $planned->usages->blocked !== []
|
||
? ['blocked_ids' => array_slice($planned->usages->blocked, 0, self::LISTED_IDS)] : [])
|
||
);
|
||
}
|
||
|
||
if (($input['design_rev'] ?? null) !== $rev) {
|
||
return Errors::revMismatch(
|
||
sprintf('Die %s wurden seit dem Probelauf geändert; nichts wurde gespeichert.', $area->label()),
|
||
'Erneut ohne confirm aufrufen (Probelauf), dem Nutzer die Änderung zeigen und mit der neuen '
|
||
. 'design_rev bestätigen.'
|
||
);
|
||
}
|
||
|
||
$blocked = $this->blocked($planned);
|
||
if ($blocked !== null) {
|
||
return $blocked;
|
||
}
|
||
|
||
ReleaseGate::assertWritable();
|
||
try {
|
||
$snapshotId = $this->snapshots->save($area->name(), $current, $ability);
|
||
} catch (SnapshotTooLarge $e) {
|
||
return Errors::make(
|
||
'snapshot_too_large',
|
||
sprintf(
|
||
'Die %s sind zu groß für eine Sicherung (%d Bytes, höchstens %d); nichts wurde gespeichert.',
|
||
$area->label(),
|
||
$e->size,
|
||
SnapshotStore::MAX_BYTES
|
||
),
|
||
'Ohne Sicherung schreibt LIT MCP nicht. Die Änderung im Divi-Builder vornehmen oder unbenutzte '
|
||
. 'Einträge dort entfernen.',
|
||
413
|
||
);
|
||
}
|
||
|
||
$saved = $this->write($ability, $area, $planned, $current, $snapshotId);
|
||
$logId = $this->log->record(
|
||
$ability,
|
||
self::OBJECT_TYPE,
|
||
$area->name(),
|
||
$planned->summary,
|
||
snapshotId: $snapshotId
|
||
);
|
||
$this->snapshots->attachLog($area->name(), $snapshotId, $logId);
|
||
|
||
return ToolResult::ok($planned->summary)
|
||
->snapshotId($snapshotId)
|
||
->logId($logId)
|
||
->warnings($planned->warnings)
|
||
->with('diff', $planned->diff)
|
||
->with('design_rev', $area->rev($saved))
|
||
->toArray();
|
||
}
|
||
|
||
/** Spec §4 / rulings P8, P8a: a confirmed change is refused while the caller may not edit a live post it changes. */
|
||
private function blocked(DesignPlan $plan): ?WP_Error
|
||
{
|
||
$usages = $plan->usages;
|
||
if ($usages === null || ! $usages->blocks()) {
|
||
return null;
|
||
}
|
||
|
||
return Errors::make(
|
||
'forbidden',
|
||
$usages->tooMany
|
||
? 'Die Änderung betrifft zu viele veröffentlichte Seiten, um sie einzeln zu prüfen; nichts wurde '
|
||
. 'gespeichert.'
|
||
: 'Die Änderung wirkt auf veröffentlichte Seiten, die dieses Konto nicht bearbeiten darf; nichts wurde '
|
||
. 'gespeichert.',
|
||
'Einen Administrator die Änderung vornehmen lassen oder sie im Divi-Builder ausführen.',
|
||
403,
|
||
['blocked_ids' => array_slice($usages->blocked, 0, self::LISTED_IDS)]
|
||
+ ($usages->blockedHidden > 0 ? ['blocked_hidden_count' => $usages->blockedHidden] : [])
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $current
|
||
* @return array<array-key, mixed>
|
||
* @throws WriteFailed
|
||
*/
|
||
private function write(string $ability, DesignArea $area, DesignPlan $plan, array $current, string $snapshotId): array
|
||
{
|
||
try {
|
||
$saved = $area->write($plan->raw, $plan->mayRemove);
|
||
if (! $area->verify($plan->raw, $saved)) {
|
||
throw new RuntimeException(sprintf('%s read back differs from what was written', $area->name()));
|
||
}
|
||
|
||
return $saved;
|
||
} catch (Throwable $e) {
|
||
if (DiviLocked::in($e) !== null) {
|
||
throw $e;
|
||
}
|
||
$this->rollBack($ability, $area, $current, $snapshotId, $e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $current
|
||
* @throws WriteFailed Always.
|
||
*/
|
||
private function rollBack(string $ability, DesignArea $area, array $current, string $snapshotId, Throwable $cause): never
|
||
{
|
||
$reason = sprintf('Die %s konnten nicht gespeichert werden.', $area->label());
|
||
$failures = Rollback::attempt(static fn () => $area->write($current, true));
|
||
if ($failures === []) {
|
||
throw WriteFailed::rolledBack(
|
||
$reason,
|
||
$cause,
|
||
sprintf('design %s write failed: %s', $area->name(), $cause->getMessage()),
|
||
details: ['snapshot_id' => $snapshotId]
|
||
);
|
||
}
|
||
|
||
$this->log->recordPartial(
|
||
$ability,
|
||
self::OBJECT_TYPE,
|
||
$area->name(),
|
||
sprintf('%s nicht zurückgenommen (%s); Snapshot %s', $area->label(), $failures[0], $snapshotId)
|
||
);
|
||
throw WriteFailed::partial(
|
||
$reason,
|
||
$cause,
|
||
sprintf('design %s rollback failed: %s', $area->name(), $failures[0]),
|
||
state: sprintf('Die %s sind möglicherweise teilweise geändert (Stand vorher: Snapshot %s).', $area->label(), $snapshotId),
|
||
hint: sprintf(
|
||
'Den Aufruf nicht wiederholen. Mit lit/divi-design-restore und snapshot_id %s (Probelauf, dann '
|
||
. 'confirm: true) den vorherigen Stand wiederherstellen und dem Nutzer Bescheid geben.',
|
||
$snapshotId
|
||
),
|
||
details: ['snapshot_id' => $snapshotId]
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
Zeilen über 120 Zeichen meldet phpcs; sie wie im übrigen Code umbrechen (Argumente je Zeile).
|
||
|
||
- [ ] **Step 5: Test laufen lassen – er besteht**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DesignChangeTest.php`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Failing test für `PresetArea` schreiben**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetArea;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetBook;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class PresetAreaTest extends TestCase
|
||
{
|
||
public function testWritesTheRestFormatAndReadsBack(): void
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$stored = PresetBookTest::stored();
|
||
$writer->allows('presets')->andReturn($stored);
|
||
$writer->expects('savePresets')
|
||
->with(PresetBook::fromDivi($stored)->toRest(), true)
|
||
->andReturn($stored);
|
||
$area = new PresetArea($writer);
|
||
|
||
self::assertSame(['presets', 'Presets'], [$area->name(), $area->label()]);
|
||
self::assertSame(PresetBook::fromDivi($stored)->data(), $area->raw());
|
||
self::assertSame(PresetBook::fromDivi($stored)->rev(), $area->rev($stored));
|
||
self::assertSame(PresetBook::fromDivi($stored)->data(), $area->write($stored, true));
|
||
}
|
||
|
||
public function testVerifyIgnoresSanitizedValuesButNotMissingPresets(): void
|
||
{
|
||
$area = new PresetArea(Mockery::mock(DesignSystemWriter::class));
|
||
$expected = PresetBookTest::stored();
|
||
$sanitized = $expected;
|
||
$sanitized['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = ['button' => ['x' => 'bereinigt']];
|
||
$missing = $expected;
|
||
unset($missing['module']['divi/button']['items']['bbbbbbbbbb']);
|
||
$otherDefault = $expected;
|
||
$otherDefault['module']['divi/text']['default'] = '';
|
||
|
||
self::assertTrue($area->verify($expected, $sanitized));
|
||
self::assertFalse($area->verify($expected, $missing));
|
||
self::assertFalse($area->verify($expected, $otherDefault));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: `PresetArea` implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The area "presets" (spec §6): the inventory in Divi's stored format (PresetBook), written as REST format through
|
||
* DesignSystemWriter::savePresets(). Divi's prepare_data() may sanitize values; verify() only insists on the same
|
||
* presets and defaults (PresetDiff without "changed").
|
||
*/
|
||
final class PresetArea implements DesignArea
|
||
{
|
||
public function __construct(private DesignSystemWriter $writer)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'presets';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Presets';
|
||
}
|
||
|
||
public function raw(): array
|
||
{
|
||
return PresetBook::fromDivi($this->writer->presets())->data();
|
||
}
|
||
|
||
public function rev(array $raw): string
|
||
{
|
||
return PresetBook::fromDivi($raw)->rev();
|
||
}
|
||
|
||
public function write(array $raw, bool $mayRemove): array
|
||
{
|
||
return PresetBook::fromDivi($this->writer->savePresets(PresetBook::fromDivi($raw)->toRest(), $mayRemove))
|
||
->data();
|
||
}
|
||
|
||
public function verify(array $expected, array $actual): bool
|
||
{
|
||
$structural = array_filter(
|
||
PresetDiff::between(PresetBook::fromDivi($expected), PresetBook::fromDivi($actual)),
|
||
static fn (array $row): bool => $row['action'] !== 'changed'
|
||
);
|
||
|
||
return $structural === [];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 9: Gesamtlauf, Lint, Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DesignArea.php src/Integrations/Divi/Design/DesignPlan.php src/Integrations/Divi/Design/DesignChange.php src/Integrations/Divi/Design/PresetArea.php tests/Unit/Integrations/Divi/Design/DesignChangeTest.php tests/Unit/Integrations/Divi/Design/PresetAreaTest.php
|
||
git commit -m "feat: Änderungs-Pipeline des Designsystems (Probelauf, design_rev, Snapshot, Rollback) und Bereich presets" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
### Task 9: `PresetComposer` – Preset-Eintrag aus `set`, `attrs`, `from_node` und `group_presets`
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/PresetComposer.php`
|
||
- Modify: `src/Integrations/Divi/Design/DesignSystemWriter.php` (neue Methode `groupsOf()`, Manifest)
|
||
- Modify: `tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php` (Manifest-Liste)
|
||
- Test: `tests/Unit/Integrations/Divi/Design/PresetComposerTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Composer::update(Block, array, string)` (vorhanden; Rückgabe `{attrs, errors, invalid_paths}`), `StoredNodeGuard::problems(Block)` (vorhanden), `Block::create()`, `AttrMap::flatten()`, `PresetBook`, `PresetScope` (Task 2).
|
||
- Produces:
|
||
- `DesignSystemWriter::groupsOf(string $moduleName): array` – `array<string, string>` Attributpfad der Gruppe (`groupId`) → Gruppenname (`divi/spacing`), aus `GlobalPreset::get_group_preset_default_attr(ModuleRegistration::get_module_settings($moduleName))`; `[]` ohne Divi.
|
||
- `final class PresetComposer { public function __construct(Composer $composer, StoredNodeGuard $guard, Closure $presetAttrs, Closure $groupsOf, string $version, Closure $now); public function compose(PresetBook $book, PresetScope $scope, ?array $existing, array $input, string $field, ?Block $source): array }` – Rückgabe `array{item: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}`; `$presetAttrs` = `DesignSystemWriter::presetAttrs(...)`, `$groupsOf` = `DesignSystemWriter::groupsOf(...)`, `$now` liefert Millisekunden.
|
||
|
||
Regeln von `compose()` (Spec §6.3):
|
||
- Modul des Presets: Modul-Preset → `$scope->name`; Gruppen-Preset → `existing.moduleName`, sonst Modul von `$source`, sonst `divi/<source_module>` aus der Eingabe; fehlt alles → Fehler `<field>.source_module`.
|
||
- Gruppe (`groupId`) eines neuen Gruppen-Presets: der eine Pfad aus `groupsOf(<Modul>)` mit Gruppenname `$scope->name`; mehrere → Eingabe `group_id` nötig; keiner → Fehler.
|
||
- Ausgangswerte: `existing.attrs` bzw. `[]`; mit `$source`: zuerst `StoredNodeGuard::problems($source)` (Fehler `<field>.from_node`), dann die Preset-Attribute des Knotens (`presetAttrs(<Modul>, $source->attrs())['all']`, bei Gruppen nur unterhalb `groupId`) über die Ausgangswerte gelegt.
|
||
- `set`/`attrs` laufen durch `Composer::update()` an `Block::create(<Modul>, <Ausgangswerte>)`.
|
||
- Danach muss jeder Pfad Preset-Attribut sein (`presetAttrs(...)['all']` enthält ihn) – sonst `<field>.attrs.<pfad>`: „kein Preset-Attribut (Inhalte wie Texte, Links und Bilder gehören ins Modul)“; bei Gruppen zusätzlich unterhalb `groupId` – sonst „gehört nicht zur Gruppe <groupId>“.
|
||
- `group_presets` (nur Modul-Presets): `{ "<Gruppe>": "<Name|ID>" | null }`, Gruppe als Kurzname (`font`), voller Name (`divi/font`) oder Pfad (`button.decoration.font`); Auflösung über `groupsOf(<Modul>)`, Preset über `PresetBook::find()` im Bereich `group`.
|
||
- Eintrag: `type`, `id` (bestehend oder `PresetBook::newId()`), `name` (`rename` vor `name` vor bestehend), bei Gruppen `groupName`/`groupId`, `moduleName`, `version`, `created` (bestehend oder jetzt), `updated` (jetzt), `attrs`, `styleAttrs`/`renderAttrs` (nur wenn nicht leer), `groupPresets` (nur wenn nicht leer), `priority`/`order` vom bestehenden Eintrag.
|
||
|
||
- [ ] **Step 1: `groupsOf()` im Writer – Test ergänzen**
|
||
|
||
In `DesignSystemWriterTest::testIsARegisteredDesignAdapterWithTheSyncMirror` die erwartete Liste um zwei Einträge ergänzen (nach `'Conversion::get_preset_attrs()',`):
|
||
|
||
```php
|
||
'GlobalPreset::get_group_preset_default_attr()', 'ModuleRegistration::get_module_settings()',
|
||
```
|
||
|
||
und einen Test anhängen:
|
||
|
||
```php
|
||
public function testWithoutDiviAModuleHasNoGroups(): void
|
||
{
|
||
self::assertSame([], (new DesignSystemWriter())->groupsOf('divi/button'));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: `groupsOf()` implementieren**
|
||
|
||
In `DesignSystemWriter`: Import `use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;`, im Manifest nach `Conversion::get_preset_attrs` ergänzen:
|
||
|
||
```php
|
||
new Call(GlobalPreset::class . '::get_group_preset_default_attr', 1),
|
||
new Call(ModuleRegistration::class . '::get_module_settings', 1),
|
||
```
|
||
|
||
und die Methode:
|
||
|
||
```php
|
||
/**
|
||
* The option groups of $moduleName that take group presets: attribute path (groupId) => group name, e.g.
|
||
* "module.decoration.spacing" => "divi/spacing" (formats §3). [] without Divi or for an unknown module.
|
||
*
|
||
* @return array<string, string>
|
||
*/
|
||
public function groupsOf(string $moduleName): array
|
||
{
|
||
if (! class_exists(ModuleRegistration::class)) {
|
||
return [];
|
||
}
|
||
|
||
$settings = ModuleRegistration::get_module_settings($moduleName);
|
||
if ($settings === null) {
|
||
return [];
|
||
}
|
||
|
||
$groups = [];
|
||
foreach (GlobalPreset::get_group_preset_default_attr($settings) as $groupId => $group) {
|
||
if (is_array($group) && is_string($group['groupName'] ?? null)) {
|
||
$groups[(string) $groupId] = $group['groupName'];
|
||
}
|
||
}
|
||
|
||
return $groups;
|
||
}
|
||
```
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php tests/Unit/Integrations/Divi/Release`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 3: Failing test für `PresetComposer` schreiben**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetBook;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class PresetComposerTest extends TestCase
|
||
{
|
||
private const NOW = 1760000000000;
|
||
|
||
/** Divi's mapping in small: decoration paths are preset attributes, innerContent is content. */
|
||
private static function presetAttrs(): \Closure
|
||
{
|
||
return static function (string $module, array $attrs): array {
|
||
$all = [];
|
||
foreach ($attrs as $key => $value) {
|
||
if (is_array($value)) {
|
||
$kept = array_diff_key($value, ['innerContent' => true]);
|
||
if ($kept !== []) {
|
||
$all[$key] = $kept;
|
||
}
|
||
}
|
||
}
|
||
|
||
return ['all' => $all, 'style' => $all, 'render' => []];
|
||
};
|
||
}
|
||
|
||
private static function composer(bool $unfiltered = true): PresetComposer
|
||
{
|
||
return new PresetComposer(
|
||
ComposeFixture::composer($unfiltered),
|
||
new StoredNodeGuard(
|
||
ComposeFixture::cleaner($unfiltered),
|
||
ComposeFixture::references(),
|
||
ComposeFixture::sanitizer($unfiltered)
|
||
),
|
||
self::presetAttrs(),
|
||
static fn (string $module): array => [
|
||
'module.decoration.spacing' => 'divi/spacing',
|
||
'button.decoration.font' => 'divi/font',
|
||
],
|
||
'5.15.1',
|
||
static fn (): int => self::NOW
|
||
);
|
||
}
|
||
|
||
private static function book(): PresetBook
|
||
{
|
||
return PresetBook::fromDivi(PresetBookTest::stored());
|
||
}
|
||
|
||
public function testNewModulePresetFromAttrs(): void
|
||
{
|
||
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [
|
||
'name' => 'CTA sekundär',
|
||
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#123456']]],
|
||
], 'items[0]', null);
|
||
|
||
self::assertSame([], $result['errors']);
|
||
$item = $result['item'];
|
||
self::assertMatchesRegularExpression('/^[0-9a-z]{10}$/', $item['id']);
|
||
self::assertSame(['module', 'CTA sekundär', 'divi/button', '5.15.1'], [$item['type'], $item['name'],
|
||
$item['moduleName'], $item['version']]);
|
||
self::assertSame([self::NOW, self::NOW], [$item['created'], $item['updated']]);
|
||
self::assertSame('#123456', $item['attrs']['button']['decoration']['background']['desktop']['value']['color']);
|
||
self::assertSame($item['attrs'], $item['styleAttrs']);
|
||
self::assertArrayNotHasKey('renderAttrs', $item);
|
||
}
|
||
|
||
public function testContentPathsAreRefused(): void
|
||
{
|
||
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [
|
||
'name' => 'Mit Text',
|
||
'set' => ['text' => 'Jetzt buchen'],
|
||
], 'items[0]', null);
|
||
|
||
self::assertArrayHasKey('items[0].attrs.button.innerContent.desktop.value.text', $result['errors']);
|
||
self::assertStringContainsString('kein Preset-Attribut', $result['errors']['items[0].attrs.button.innerContent.desktop.value.text']);
|
||
}
|
||
|
||
public function testChangingMergesKeepsIdCreatedAndOrder(): void
|
||
{
|
||
$existing = self::book()->items(new PresetScope('module', 'divi/button'))['aaaaaaaaaa'];
|
||
$existing['created'] = 5;
|
||
$existing['order'] = 3;
|
||
$existing['attrs'] = ['button' => ['decoration' => ['border' => ['desktop' => ['value' => ['radius' => '4px']]]]]];
|
||
|
||
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), $existing, [
|
||
'rename' => 'CTA Primär',
|
||
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#000000']]],
|
||
], 'items[0]', null);
|
||
|
||
self::assertSame([], $result['errors']);
|
||
self::assertSame(['aaaaaaaaaa', 'CTA Primär', 5, self::NOW, 3], [$result['item']['id'], $result['item']['name'],
|
||
$result['item']['created'], $result['item']['updated'], $result['item']['order']]);
|
||
self::assertSame('4px', $result['item']['attrs']['button']['decoration']['border']['desktop']['value']['radius']);
|
||
}
|
||
|
||
public function testFromNodeTakesTheDesignButNotTheContent(): void
|
||
{
|
||
$node = Block::create('divi/button', [
|
||
'button' => [
|
||
'innerContent' => ['desktop' => ['value' => ['text' => 'Hallo']]],
|
||
'decoration' => ['background' => ['desktop' => ['value' => ['color' => '#abcdef']]]],
|
||
],
|
||
]);
|
||
|
||
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [
|
||
'name' => 'Aus Seite',
|
||
], 'items[0]', $node);
|
||
|
||
self::assertSame([], $result['errors']);
|
||
self::assertSame('#abcdef', $result['item']['attrs']['button']['decoration']['background']['desktop']['value']['color']);
|
||
self::assertArrayNotHasKey('innerContent', $result['item']['attrs']['button']);
|
||
}
|
||
|
||
public function testEditorCannotCarryBuilderOnlyValuesFromANode(): void
|
||
{
|
||
$node = Block::create('divi/button', [
|
||
'css' => ['desktop' => ['value' => ['mainElement' => 'color:red']]],
|
||
'button' => ['decoration' => ['background' => ['desktop' => ['value' => ['color' => '#000000']]]]],
|
||
]);
|
||
|
||
$result = self::composer(false)->compose(self::book(), new PresetScope('module', 'divi/button'), null, [
|
||
'name' => 'Mit CSS',
|
||
], 'items[0]', $node);
|
||
|
||
self::assertArrayHasKey('items[0].from_node', $result['errors']);
|
||
}
|
||
|
||
public function testGroupPresetOnlyTakesItsGroup(): void
|
||
{
|
||
$result = self::composer()->compose(self::book(), new PresetScope('group', 'divi/spacing'), null, [
|
||
'name' => 'Abstand groß',
|
||
'source_module' => 'button',
|
||
'attrs' => [
|
||
'module.decoration.spacing' => ['desktop' => ['padding' => ['top' => '40px']]],
|
||
'button.decoration.background' => ['desktop' => ['color' => '#000000']],
|
||
],
|
||
], 'items[0]', null);
|
||
|
||
self::assertSame(
|
||
'gehört nicht zur Gruppe module.decoration.spacing',
|
||
$result['errors']['items[0].attrs.button.decoration.background.desktop.value.color']
|
||
);
|
||
self::assertSame(['group', 'divi/spacing', 'module.decoration.spacing', 'divi/button'], [
|
||
$result['item']['type'], $result['item']['groupName'], $result['item']['groupId'],
|
||
$result['item']['moduleName'],
|
||
]);
|
||
}
|
||
|
||
public function testNestedGroupPresetsByShortName(): void
|
||
{
|
||
$book = self::book()->with(new PresetScope('group', 'divi/font'), [
|
||
'type' => 'group', 'id' => 'ffffffffff', 'name' => 'Button-Schrift', 'groupName' => 'divi/font',
|
||
'groupId' => 'button.decoration.font', 'moduleName' => 'divi/button', 'attrs' => [],
|
||
]);
|
||
|
||
$result = self::composer()->compose($book, new PresetScope('module', 'divi/button'), null, [
|
||
'name' => 'Mit Schrift',
|
||
'group_presets' => ['font' => 'Button-Schrift'],
|
||
], 'items[0]', null);
|
||
|
||
self::assertSame([], $result['errors']);
|
||
self::assertSame(
|
||
['button.decoration.font' => ['presetId' => ['ffffffffff'], 'groupName' => 'divi/font']],
|
||
$result['item']['groupPresets']
|
||
);
|
||
}
|
||
|
||
public function testUnknownGroupAndPresetAreErrors(): void
|
||
{
|
||
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [
|
||
'name' => 'X',
|
||
'group_presets' => ['sizing' => 'Gibt es nicht', 'font' => 'Fehlt'],
|
||
], 'items[0]', null);
|
||
|
||
self::assertStringContainsString('keine Gruppe', $result['errors']['items[0].group_presets.sizing']);
|
||
self::assertStringContainsString('unbekanntes Gruppen-Preset', $result['errors']['items[0].group_presets.font']);
|
||
}
|
||
|
||
public function testNewPresetNeedsAName(): void
|
||
{
|
||
$result = self::composer()->compose(self::book(), new PresetScope('module', 'divi/button'), null, [],
|
||
'items[0]', null);
|
||
|
||
self::assertSame('Name des neuen Presets fehlt', $result['errors']['items[0].name']);
|
||
}
|
||
}
|
||
```
|
||
|
||
Hinweis zu den Fehlerschlüsseln: `PresetComposer` meldet unerlaubte Pfade auf **Blattebene** (`AttrMap::flatten()`-Schlüssel) mit dem Präfix `<field>.attrs.`. `Composer::update()` speichert die Kurzform `{"desktop": {"color": …}}` als `desktop.value.color`; daher die Schlüssel im Test.
|
||
|
||
`StoredNodeGuard` sperrt für Benutzer ohne `unfiltered_html` jeden gespeicherten Pfad, der `ValueCleaner::BUILDER_ONLY` trifft (`css`, `css.*`, `*.css.*`, `decoration.attributes` …) – unabhängig vom Katalog; der Knoten im Test trägt `css.desktop.value.mainElement`.
|
||
|
||
- [ ] **Step 4: Test laufen lassen – er scheitert**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetComposerTest.php`
|
||
Expected: FAIL, Klasse fehlt.
|
||
|
||
- [ ] **Step 5: Implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use Closure;
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\Compose\Composer;
|
||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Builds one preset item (spec §6.3, formats §3) from the tool input: the short form ("set") and the full form
|
||
* ("attrs") pass Composer::update() on a virtual block of the preset's module – the same choke point, placeholders
|
||
* and S7/S26 locks as for modules (D7) –, from_node takes the preset attributes of a stored node after
|
||
* StoredNodeGuard (E6), group_presets nest option group presets. Every resulting path must be a preset attribute
|
||
* of the module (Divi's mapping, $presetAttrs) and, for a group preset, lie below its group. styleAttrs/renderAttrs
|
||
* are split with the same mapping, like the Visual Builder does. Pure PHP; Divi enters through the closures.
|
||
*/
|
||
final class PresetComposer
|
||
{
|
||
private const CONTENT = 'kein Preset-Attribut (Inhalte wie Texte, Links und Bilder gehören ins Modul)';
|
||
|
||
/**
|
||
* @param Closure(string, array<string, mixed>): array{all: array<string, mixed>, style: array<string, mixed>,
|
||
* render: array<string, mixed>} $presetAttrs DesignSystemWriter::presetAttrs()
|
||
* @param Closure(string): array<string, string> $groupsOf DesignSystemWriter::groupsOf()
|
||
* @param Closure(): int $now Milliseconds, like the builder's Date.now().
|
||
*/
|
||
public function __construct(
|
||
private Composer $composer,
|
||
private StoredNodeGuard $guard,
|
||
private Closure $presetAttrs,
|
||
private Closure $groupsOf,
|
||
private string $version,
|
||
private Closure $now
|
||
) {
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed>|null $existing The preset to change, null for a new one.
|
||
* @param array<string, mixed> $input One item of lit/divi-presets-set.
|
||
* @return array{item: array<string, mixed>, errors: array<string, string>, invalid_paths: list<string>}
|
||
*/
|
||
public function compose(
|
||
PresetBook $book,
|
||
PresetScope $scope,
|
||
?array $existing,
|
||
array $input,
|
||
string $field,
|
||
?Block $source
|
||
): array {
|
||
$errors = [];
|
||
$module = $this->moduleOf($scope, $existing, $input, $source);
|
||
if ($module === null) {
|
||
$errors[$field . '.source_module'] = 'Modul des Gruppen-Presets fehlt (source_module oder from_node)';
|
||
|
||
return ['item' => [], 'errors' => $errors, 'invalid_paths' => []];
|
||
}
|
||
|
||
$groupId = null;
|
||
if ($scope->kind === PresetScope::GROUP) {
|
||
$groupId = $this->groupIdOf($scope, $module, $existing, $input, $field, $errors);
|
||
if ($groupId === null) {
|
||
return ['item' => [], 'errors' => $errors, 'invalid_paths' => []];
|
||
}
|
||
}
|
||
|
||
$attrs = is_array($existing['attrs'] ?? null) ? $existing['attrs'] : [];
|
||
if ($source !== null) {
|
||
$problems = $this->guard->problems($source);
|
||
if ($problems !== []) {
|
||
$errors[$field . '.from_node'] = implode('; ', $problems);
|
||
}
|
||
$taken = ($this->presetAttrs)($module, $source->attrs())['all'];
|
||
$attrs = array_replace_recursive($attrs, $groupId === null ? $taken : self::below($taken, $groupId));
|
||
}
|
||
|
||
$change = array_intersect_key($input, ['set' => true, 'attrs' => true]);
|
||
if ($change !== []) {
|
||
$updated = $this->composer->update(Block::create($module, $attrs), $change, $field);
|
||
$attrs = $updated['attrs'];
|
||
$errors += $updated['errors'];
|
||
if ($updated['invalid_paths'] !== []) {
|
||
return ['item' => [], 'errors' => $errors, 'invalid_paths' => $updated['invalid_paths']];
|
||
}
|
||
}
|
||
|
||
$split = ($this->presetAttrs)($module, $attrs);
|
||
$allowed = AttrMap::flatten($split['all']);
|
||
foreach (array_keys(AttrMap::flatten($attrs)) as $path) {
|
||
if (! array_key_exists($path, $allowed)) {
|
||
$errors[$field . '.attrs.' . $path] = self::CONTENT;
|
||
} elseif ($groupId !== null && ! str_starts_with($path, $groupId . '.')) {
|
||
$errors[$field . '.attrs.' . $path] = 'gehört nicht zur Gruppe ' . $groupId;
|
||
}
|
||
}
|
||
|
||
$nested = $this->groupPresets($book, $scope, $module, $existing, $input, $field, $errors);
|
||
$name = $this->nameOf($existing, $input, $field, $errors);
|
||
|
||
return [
|
||
'item' => $this->item($scope, $existing, $name, $module, $groupId, $attrs, $split, $nested),
|
||
'errors' => $errors,
|
||
'invalid_paths' => [],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed>|null $existing
|
||
* @param array<string, mixed> $input
|
||
*/
|
||
private function moduleOf(PresetScope $scope, ?array $existing, array $input, ?Block $source): ?string
|
||
{
|
||
if ($scope->kind === PresetScope::MODULE) {
|
||
return $scope->name;
|
||
}
|
||
if (is_string($existing['moduleName'] ?? null)) {
|
||
return $existing['moduleName'];
|
||
}
|
||
if ($source !== null) {
|
||
return $source->name();
|
||
}
|
||
$short = $input['source_module'] ?? null;
|
||
if (! is_string($short) || preg_match('/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)?$/D', $short) !== 1) {
|
||
return null;
|
||
}
|
||
|
||
return str_contains($short, '/') ? $short : 'divi/' . $short;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed>|null $existing
|
||
* @param array<string, mixed> $input
|
||
* @param array<string, string> $errors
|
||
*/
|
||
private function groupIdOf(
|
||
PresetScope $scope,
|
||
string $module,
|
||
?array $existing,
|
||
array $input,
|
||
string $field,
|
||
array &$errors
|
||
): ?string {
|
||
if (is_string($existing['groupId'] ?? null)) {
|
||
return $existing['groupId'];
|
||
}
|
||
|
||
$paths = array_keys(array_filter(
|
||
($this->groupsOf)($module),
|
||
static fn (string $group): bool => $group === $scope->name
|
||
));
|
||
$wanted = $input['group_id'] ?? null;
|
||
if (is_string($wanted) && in_array($wanted, $paths, true)) {
|
||
return $wanted;
|
||
}
|
||
if (count($paths) === 1 && $wanted === null) {
|
||
return (string) $paths[0];
|
||
}
|
||
|
||
$errors[$field . '.group_id'] = $paths === []
|
||
? sprintf('%s hat keine Gruppe %s', $module, $scope->name)
|
||
: sprintf('mehrere Gruppen %s in %s – group_id angeben: %s', $scope->name, $module, implode(', ', $paths));
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed>|null $existing
|
||
* @param array<string, mixed> $input
|
||
* @param array<string, string> $errors
|
||
* @return array<string, array{presetId: list<string>, groupName: string}>
|
||
*/
|
||
private function groupPresets(
|
||
PresetBook $book,
|
||
PresetScope $scope,
|
||
string $module,
|
||
?array $existing,
|
||
array $input,
|
||
string $field,
|
||
array &$errors
|
||
): array {
|
||
$nested = is_array($existing['groupPresets'] ?? null) ? $existing['groupPresets'] : [];
|
||
$wanted = $input['group_presets'] ?? null;
|
||
if ($wanted === null) {
|
||
return $nested;
|
||
}
|
||
if ($scope->kind !== PresetScope::MODULE || ! is_array($wanted)) {
|
||
$errors[$field . '.group_presets'] = 'nur bei Modul-Presets, als Objekt Gruppe → Preset';
|
||
|
||
return $nested;
|
||
}
|
||
|
||
$groups = ($this->groupsOf)($module);
|
||
foreach ($wanted as $group => $preset) {
|
||
$path = $field . '.group_presets.' . $group;
|
||
$groupId = self::findGroup($groups, (string) $group);
|
||
if ($groupId === null) {
|
||
$errors[$path] = sprintf('keine Gruppe "%s" in %s', $group, $module);
|
||
continue;
|
||
}
|
||
if ($preset === null) {
|
||
unset($nested[$groupId]);
|
||
continue;
|
||
}
|
||
try {
|
||
$found = is_string($preset) ? $book->find(new PresetScope(PresetScope::GROUP, $groups[$groupId]), $preset)
|
||
: null;
|
||
} catch (InvalidArgumentException $e) {
|
||
$errors[$path] = $e->getMessage();
|
||
continue;
|
||
}
|
||
if ($found === null) {
|
||
$errors[$path] = sprintf('unbekanntes Gruppen-Preset "%s" (%s)', (string) $preset, $groups[$groupId]);
|
||
continue;
|
||
}
|
||
$nested[$groupId] = ['presetId' => [(string) $found['id']], 'groupName' => $groups[$groupId]];
|
||
}
|
||
|
||
return $nested;
|
||
}
|
||
|
||
/** @param array<string, string> $groups groupId => groupName */
|
||
private static function findGroup(array $groups, string $wanted): ?string
|
||
{
|
||
foreach ($groups as $groupId => $name) {
|
||
if ($wanted === $groupId || $wanted === $name || 'divi/' . $wanted === $name) {
|
||
return (string) $groupId;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed>|null $existing
|
||
* @param array<string, mixed> $input
|
||
* @param array<string, string> $errors
|
||
*/
|
||
private function nameOf(?array $existing, array $input, string $field, array &$errors): string
|
||
{
|
||
foreach (['rename', 'name'] as $key) {
|
||
if (is_string($input[$key] ?? null) && trim($input[$key]) !== '') {
|
||
return trim($input[$key]);
|
||
}
|
||
}
|
||
if (is_string($existing['name'] ?? null)) {
|
||
return $existing['name'];
|
||
}
|
||
$errors[$field . '.name'] = 'Name des neuen Presets fehlt';
|
||
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed>|null $existing
|
||
* @param array<string, mixed> $attrs
|
||
* @param array{all: array<string, mixed>, style: array<string, mixed>, render: array<string, mixed>} $split
|
||
* @param array<string, mixed> $nested
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function item(
|
||
PresetScope $scope,
|
||
?array $existing,
|
||
string $name,
|
||
string $module,
|
||
?string $groupId,
|
||
array $attrs,
|
||
array $split,
|
||
array $nested
|
||
): array {
|
||
$now = ($this->now)();
|
||
$item = ['type' => $scope->kind, 'id' => (string) ($existing['id'] ?? PresetBook::newId()), 'name' => $name];
|
||
if ($groupId !== null) {
|
||
$item += ['groupName' => $scope->name, 'groupId' => $groupId];
|
||
}
|
||
$item += [
|
||
'moduleName' => $module,
|
||
'version' => $this->version,
|
||
'created' => (int) ($existing['created'] ?? $now),
|
||
'updated' => $now,
|
||
'attrs' => $attrs,
|
||
];
|
||
if ($split['style'] !== []) {
|
||
$item['styleAttrs'] = $split['style'];
|
||
}
|
||
if ($split['render'] !== []) {
|
||
$item['renderAttrs'] = $split['render'];
|
||
}
|
||
if ($nested !== []) {
|
||
$item['groupPresets'] = $nested;
|
||
}
|
||
foreach (['priority', 'order'] as $key) {
|
||
if (isset($existing[$key])) {
|
||
$item[$key] = $existing[$key];
|
||
}
|
||
}
|
||
|
||
return $item;
|
||
}
|
||
|
||
/**
|
||
* The part of $attrs below the dotted path $groupId.
|
||
*
|
||
* @param array<string, mixed> $attrs
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function below(array $attrs, string $groupId): array
|
||
{
|
||
$value = AttrMap::get($attrs, $groupId);
|
||
|
||
return $value === null ? [] : AttrMap::set([], $groupId, $value);
|
||
}
|
||
}
|
||
```
|
||
|
||
`AttrMap::get()` liest einen Punkt-Pfad (`null`, wenn er fehlt), `AttrMap::set()` legt fehlende Zwischenstufen an.
|
||
|
||
- [ ] **Step 6: Test laufen lassen – er besteht**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetComposerTest.php`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Gesamtlauf, Lint, Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/PresetComposer.php src/Integrations/Divi/Design/DesignSystemWriter.php tests/Unit/Integrations/Divi/Design/PresetComposerTest.php tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php
|
||
git commit -m "feat: PresetComposer – Preset aus Kurz-/Vollform, Modul und Gruppen-Presets" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: `lit/divi-preset-get` – ein Preset mit Einstellungen und Fundstellen lesen
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/DiviPresetGetAbility.php`
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php` (Writer-Instanz, Tool registrieren)
|
||
- Modify: `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` (Namensliste)
|
||
- Test: `tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DesignSystemWriter::presets()`, `PresetBook`, `PresetScope`, `PresetUsageQuery`, `GlobalUsages::reportFor()`, `UsageReport::listing()`, `ShortformMap::values()`, `AttrMap::flatten()`, `JsonMap::of()`, `Annotations::read()`, `Errors`.
|
||
- Produces: `final class DiviPresetGetAbility implements Ability { public function __construct(DesignSystemWriter $writer, GlobalUsages $usages) }`; Name `lit/divi-preset-get`; Ausgabe `{id, name, kind, scope, default, module_name, group_id, set, attrs, group_presets, usage, design_rev}`.
|
||
|
||
- [ ] **Step 1: Failing test schreiben**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetBook;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class DiviPresetGetAbilityTest extends TestCase
|
||
{
|
||
private function ability(?GlobalUsages $usages = null): DiviPresetGetAbility
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('presets')->andReturn(PresetBookTest::stored());
|
||
$usages ??= Mockery::mock(GlobalUsages::class);
|
||
|
||
return new DiviPresetGetAbility($writer, $usages);
|
||
}
|
||
|
||
public function testReadsAPresetWithItsUsage(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$usages = Mockery::mock(GlobalUsages::class);
|
||
$usages->expects('reportFor')
|
||
->with(Mockery::type(PresetUsageQuery::class), false)
|
||
->andReturn(new UsageReport(1, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish',
|
||
'type' => 'page']], 0, 1, [], 0, false));
|
||
$ability = $this->ability($usages);
|
||
|
||
self::assertTrue($ability->permission(['module' => 'text', 'preset' => 'Fließtext']));
|
||
$result = $ability->execute(['module' => 'text', 'preset' => 'Fließtext']);
|
||
|
||
self::assertSame(['dddddddddd', 'Fließtext', 'module', 'divi/text', true], [$result['id'], $result['name'],
|
||
$result['kind'], $result['scope'], $result['default']]);
|
||
self::assertSame([[
|
||
'group_id' => 'content.decoration.bodyFont',
|
||
'group_name' => 'divi/font-body',
|
||
'presets' => [['id' => 'gggggggggg', 'name' => 'Lesetext']],
|
||
]], $result['group_presets']);
|
||
self::assertSame(3, $result['usage']['affected'][0]['post_id']);
|
||
self::assertSame(PresetBook::fromDivi(PresetBookTest::stored())->rev(), $result['design_rev']);
|
||
self::assertFalse($ability->isDirect());
|
||
self::assertTrue($ability->annotations()['readonly']);
|
||
}
|
||
|
||
public function testFlattensAttrsLikeNodeGet(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$usages = Mockery::mock(GlobalUsages::class);
|
||
$usages->allows('reportFor')->andReturn(new UsageReport(0, [], 0, 0, [], 0, false));
|
||
|
||
$result = $this->ability($usages)->execute(['module' => 'button', 'preset' => 'aaaaaaaaaa']);
|
||
|
||
self::assertSame(['button.x' => 1], (array) $result['attrs']);
|
||
}
|
||
|
||
public function testUnknownAndAmbiguousPresets(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
|
||
self::assertToolError($this->ability()->execute(['module' => 'button', 'preset' => 'Fehlt']), 'not_found');
|
||
$ambiguous = self::assertToolError($this->ability()->execute(['module' => 'button', 'preset' => 'Doppelt']),
|
||
'validation_failed');
|
||
self::assertStringContainsString('bbbbbbbbbb, cccccccccc', $ambiguous['fields']['preset']);
|
||
self::assertToolError($this->ability()->execute(['preset' => 'x']), 'validation_failed');
|
||
}
|
||
|
||
public function testNeedsEditPosts(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
|
||
self::assertToolError($this->ability()->permission([]), 'forbidden');
|
||
}
|
||
}
|
||
```
|
||
|
||
`AttrMap::flatten()` liefert für `['button' => ['x' => 1]]` den Schlüssel `button.x` (Blätter); `JsonMap::of()` gibt ein `stdClass` zurück, daher der Cast im Test.
|
||
|
||
- [ ] **Step 2: Test laufen lassen – er scheitert**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php`
|
||
Expected: FAIL, Klasse fehlt.
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\JsonMap;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||
use LIT\MCP\Integrations\Divi\Schema\ShortformMap;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/divi-preset-get (spec §6.2): one module or option group preset with its settings – short form where the
|
||
* module's short form reaches them, the full form as attribute path => value like lit/divi-node-get –, its nested
|
||
* group presets with names, the posts that use it (readable ones by title, the rest as a count) and the design_rev
|
||
* for a following lit/divi-presets-set.
|
||
*/
|
||
final class DiviPresetGetAbility implements Ability
|
||
{
|
||
public function __construct(private DesignSystemWriter $writer, private GlobalUsages $usages)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/divi-preset-get';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Divi-Preset lesen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Liest ein Modul-Preset (module, z. B. "button") oder Gruppen-Preset (group, z. B. "font-body") mit '
|
||
. 'seinen Einstellungen (set in Kurzform, attrs als Attributpfad → Wert), verschachtelten Gruppen-Presets, '
|
||
. 'den Seiten, die es verwenden (usage), und der design_rev für lit/divi-presets-set. preset = Name oder '
|
||
. 'ID aus lit/divi-design-system (direkt: lit-divi-design-system).';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'module' => ['type' => 'string', 'description' => 'Modul, z. B. "button" (Modul-Preset).'],
|
||
'group' => ['type' => 'string', 'description' => 'Gruppe, z. B. "font-body" (Gruppen-Preset).'],
|
||
'preset' => ['type' => 'string', 'description' => 'Name oder ID des Presets.'],
|
||
],
|
||
'required' => ['preset'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'id' => ['type' => 'string'],
|
||
'name' => ['type' => 'string'],
|
||
'kind' => ['type' => 'string'],
|
||
'scope' => ['type' => 'string'],
|
||
'default' => ['type' => 'boolean'],
|
||
'module_name' => ['type' => 'string'],
|
||
'group_id' => ['type' => ['string', 'null']],
|
||
'set' => ['type' => 'object'],
|
||
'attrs' => ['type' => 'object'],
|
||
'group_presets' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'usage' => ['type' => 'object'],
|
||
'design_rev' => ['type' => 'string'],
|
||
],
|
||
'required' => ['id', 'name', 'kind', 'scope', 'default', 'module_name', 'group_id', 'set', 'attrs',
|
||
'group_presets', 'usage', 'design_rev'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::read();
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return current_user_can('edit_posts')
|
||
? true
|
||
: Errors::forbidden('Presets lesen erfordert das Recht, Beiträge zu bearbeiten.');
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
try {
|
||
$scope = PresetScope::fromInput($input, 'input');
|
||
} catch (InvalidArgumentException $e) {
|
||
return Errors::invalidInput('Modul oder Gruppe fehlt bzw. ist ungültig.', ['module' => $e->getMessage()]);
|
||
}
|
||
|
||
$book = PresetBook::fromDivi($this->writer->presets());
|
||
$wanted = is_string($input['preset'] ?? null) ? $input['preset'] : '';
|
||
try {
|
||
$item = $book->find($scope, $wanted);
|
||
} catch (InvalidArgumentException $e) {
|
||
return Errors::invalidInput('Der Preset-Name ist mehrdeutig.', ['preset' => $e->getMessage()]);
|
||
}
|
||
if ($item === null) {
|
||
return Errors::notFound(
|
||
sprintf('%s „%s“ gibt es nicht.', $scope->label(), $wanted),
|
||
'Vorhandene Presets mit lit/divi-design-system (direkt: lit-divi-design-system) und module auflisten.'
|
||
);
|
||
}
|
||
|
||
$id = (string) $item['id'];
|
||
$isDefault = $book->defaultId($scope) === $id;
|
||
$attrs = is_array($item['attrs'] ?? null) ? $item['attrs'] : [];
|
||
$module = is_string($item['moduleName'] ?? null) ? $item['moduleName'] : $scope->name;
|
||
$usage = $this->usages->reportFor(
|
||
new PresetUsageQuery([['scope' => $scope, 'id' => $id, 'default' => $isDefault]]),
|
||
false
|
||
);
|
||
|
||
return [
|
||
'id' => $id,
|
||
'name' => (string) ($item['name'] ?? $id),
|
||
'kind' => $scope->kind,
|
||
'scope' => $scope->name,
|
||
'default' => $isDefault,
|
||
'module_name' => $module,
|
||
'group_id' => is_string($item['groupId'] ?? null) ? $item['groupId'] : null,
|
||
'set' => JsonMap::of(ShortformMap::values(substr($module, (int) strpos($module, '/') + 1), $attrs)),
|
||
'attrs' => JsonMap::of(AttrMap::flatten($attrs)),
|
||
'group_presets' => $this->nested($book, $item),
|
||
'usage' => $usage->listing(),
|
||
'design_rev' => $book->rev(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $item
|
||
* @return list<array{group_id: string, group_name: string, presets: list<array{id: string, name: string}>}>
|
||
*/
|
||
private function nested(PresetBook $book, array $item): array
|
||
{
|
||
$rows = [];
|
||
foreach (is_array($item['groupPresets'] ?? null) ? $item['groupPresets'] : [] as $groupId => $nested) {
|
||
$groupName = is_array($nested) && is_string($nested['groupName'] ?? null) ? $nested['groupName'] : '';
|
||
$presets = [];
|
||
foreach (is_array($nested) ? (array) ($nested['presetId'] ?? []) : [] as $presetId) {
|
||
$found = $book->items(new PresetScope(PresetScope::GROUP, $groupName))[(string) $presetId] ?? null;
|
||
$presets[] = ['id' => (string) $presetId, 'name' => (string) ($found['name'] ?? $presetId)];
|
||
}
|
||
$rows[] = ['group_id' => (string) $groupId, 'group_name' => $groupName, 'presets' => $presets];
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
}
|
||
```
|
||
|
||
`ShortformMap::values()` erwartet den kurzen Modultyp (`button`), wie `Block::shortName()` ihn aus `divi/button` bildet; für einen Typ ohne Kurzform liefert es `[]` (`fields()` gibt dann `[]`).
|
||
|
||
In `DiviIntegration`:
|
||
- Eigenschaft `private DesignSystemWriter $designWriter;`, im Konstruktor `$this->designWriter = new DesignSystemWriter();`.
|
||
- In `abilities()` am Ende `new DiviPresetGetAbility($this->designWriter, $this->globalUsages()),` ergänzen; Import.
|
||
|
||
In `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` die Namensliste um `'lit/divi-preset-get',` ergänzen (hinter `'lit/divi-dynamic-sources',`).
|
||
|
||
- [ ] **Step 4: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php tests/Unit/Guides/ToolNamingTest.php`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Gesamtlauf, Lint, Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DiviPresetGetAbility.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php
|
||
git commit -m "feat: lit/divi-preset-get – Preset mit Einstellungen und Fundstellen lesen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
### Task 11: `lit/divi-presets-set` – Presets anlegen und ändern
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/DesignRights.php`
|
||
- Create: `src/Integrations/Divi/Design/DiviPresetsSetAbility.php`
|
||
- Modify: `src/Integrations/Divi/Design/PresetDiff.php` (neue Methode `summary()`)
|
||
- Modify: `tests/Unit/Integrations/Divi/Design/PresetDiffTest.php`
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/DesignRightsTest.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DesignChange::run()`, `DesignChange::inputProperties()`, `DesignChange::outputSchema()`, `DesignPlan`, `PresetArea`, `PresetBook`, `PresetScope`, `PresetDiff`, `PresetComposer`, `PresetUsageQuery`, `GlobalUsages::reportFor()`, `DiviPageLoader::load(mixed, bool)` (→ `DiviPage|WP_Error`, `$page->index->find(string): ?Block`).
|
||
- Produces:
|
||
- `final class DesignRights { public static function presets(DesignSystemWriter $writer): ?WP_Error }` – `edit_theme_options` + Visual Builder.
|
||
- `PresetDiff::summary(array $diff): string` – z. B. „Presets: 1 angelegt, 1 geändert, Standard 1× gesetzt.“
|
||
- `final class DiviPresetsSetAbility implements Ability { public const MAX_ITEMS = 20; public function __construct(DesignSystemWriter $writer, DesignChange $change, GlobalUsages $usages, DiviPageLoader $loader, Closure $composer) }` – `$composer`: `Closure(): PresetComposer`.
|
||
|
||
Eingabe (Spec §6.3): `items` (1–20) mit je `module`|`group`, `preset` (zu ändern: Name oder ID; fehlt → neu), `name` (neu), `rename`, `set`, `attrs`, `from_node` (`{post_id, id}`), `source_module`, `group_id`, `group_presets`, `make_default` (bool); dazu `confirm`, `design_rev`.
|
||
|
||
- [ ] **Step 1: Failing tests für `PresetDiff::summary()` und `DesignRights` schreiben**
|
||
|
||
In `PresetDiffTest` anhängen:
|
||
|
||
```php
|
||
public function testSummaryCountsTheActions(): void
|
||
{
|
||
self::assertSame(
|
||
'Presets: 1 angelegt, 1 geändert, 1 gelöscht, Standard 1× gesetzt, 1× entfernt.',
|
||
PresetDiff::summary([
|
||
['action' => 'added'], ['action' => 'changed'], ['action' => 'removed'],
|
||
['action' => 'default_set'], ['action' => 'default_cleared'],
|
||
])
|
||
);
|
||
self::assertSame('Presets: 2 angelegt.', PresetDiff::summary([['action' => 'added'], ['action' => 'added']]));
|
||
self::assertSame('Presets: keine Änderung.', PresetDiff::summary([]));
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Design/DesignRightsTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignRights;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class DesignRightsTest extends TestCase
|
||
{
|
||
public function testPresetsNeedEditThemeOptionsAndTheVisualBuilder(): void
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true, false);
|
||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
|
||
|
||
self::assertNull(DesignRights::presets($writer));
|
||
$refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden');
|
||
self::assertStringContainsString('Visual Builder', $refusal['message']);
|
||
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
$refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden');
|
||
self::assertStringContainsString('edit_theme_options', $refusal['message']);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – sie scheitern**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetDiffTest.php tests/Unit/Integrations/Divi/Design/DesignRightsTest.php`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: `summary()` und `DesignRights` implementieren**
|
||
|
||
In `PresetDiff`:
|
||
|
||
```php
|
||
/** One German sentence for the dry run and the change log (spec §4 step 6). */
|
||
public static function summary(array $diff): string
|
||
{
|
||
$count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff));
|
||
$parts = array_filter([
|
||
isset($count['added']) ? $count['added'] . ' angelegt' : null,
|
||
isset($count['changed']) ? $count['changed'] . ' geändert' : null,
|
||
isset($count['removed']) ? $count['removed'] . ' gelöscht' : null,
|
||
]);
|
||
$defaults = array_filter([
|
||
isset($count['default_set']) ? $count['default_set'] . '× gesetzt' : null,
|
||
isset($count['default_cleared']) ? $count['default_cleared'] . '× entfernt' : null,
|
||
]);
|
||
if ($defaults !== []) {
|
||
$parts[] = 'Standard ' . implode(', ', $defaults);
|
||
}
|
||
|
||
return 'Presets: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.';
|
||
}
|
||
```
|
||
|
||
(`@param list<array<string, mixed>> $diff` im Docblock ergänzen.)
|
||
|
||
`src/Integrations/Divi/Design/DesignRights.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Abilities\Errors;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Who may change the design system (spec §10): presets need edit_theme_options and Divi's role right "Visual Builder
|
||
* verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1").
|
||
*/
|
||
final class DesignRights
|
||
{
|
||
public static function presets(DesignSystemWriter $writer): ?WP_Error
|
||
{
|
||
if (! current_user_can('edit_theme_options')) {
|
||
return Errors::forbidden(
|
||
'Presets ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).'
|
||
);
|
||
}
|
||
if (! $writer->canUseVisualBuilder()) {
|
||
return Errors::forbidden(
|
||
'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine Presets.',
|
||
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetDiffTest.php tests/Unit/Integrations/Divi/Design/DesignRightsTest.php`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: Failing test für die Ability schreiben**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetBook;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetComposer;
|
||
use LIT\MCP\Integrations\Divi\Design\PresetScope;
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class DiviPresetsSetAbilityTest extends TestCase
|
||
{
|
||
/** @var array<string, mixed> */
|
||
private array $stored;
|
||
|
||
private SnapshotStore $snapshots;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->stored = PresetBookTest::stored();
|
||
$this->snapshots = Mockery::mock(SnapshotStore::class);
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
}
|
||
|
||
private function ability(bool $unfiltered = true, ?GlobalUsages $usages = null, ?DiviPageLoader $loader = null): DiviPresetsSetAbility
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('presets')->andReturnUsing(fn (): array => $this->stored);
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$writer->allows('savePresets')->andReturnUsing(function (array $rest, bool $mayRemove): array {
|
||
$data = ['module' => [], 'group' => []];
|
||
foreach ($rest as $kind => $records) {
|
||
foreach ($records as $record) {
|
||
$first = $record['items'][0] ?? null;
|
||
$name = $kind === 'group' ? ($first['groupName'] ?? 'x') : ($first['moduleName'] ?? 'x');
|
||
$data[$kind][$name] = ['default' => $record['default'], 'items' => array_column($record['items'], null, 'id')];
|
||
}
|
||
}
|
||
|
||
return $this->stored = $data;
|
||
});
|
||
$usages ??= Mockery::mock(GlobalUsages::class);
|
||
$usages->allows('reportFor')->andReturn(new UsageReport(0, [], 0, 0, [], 0, false));
|
||
$composer = static fn (): PresetComposer => new PresetComposer(
|
||
ComposeFixture::composer($unfiltered),
|
||
new StoredNodeGuard(ComposeFixture::cleaner($unfiltered), ComposeFixture::references(),
|
||
ComposeFixture::sanitizer($unfiltered)),
|
||
static fn (string $module, array $attrs): array => ['all' => array_map(
|
||
static fn (mixed $v): mixed => is_array($v) ? array_diff_key($v, ['innerContent' => 1]) : $v,
|
||
$attrs
|
||
), 'style' => [], 'render' => []],
|
||
static fn (string $module): array => ['module.decoration.spacing' => 'divi/spacing'],
|
||
'5.15.1',
|
||
static fn (): int => 1
|
||
);
|
||
|
||
return new DiviPresetsSetAbility(
|
||
$writer,
|
||
new DesignChange($this->snapshots, new FakeChangeLog(5)),
|
||
$usages,
|
||
$loader ?? Mockery::mock(DiviPageLoader::class),
|
||
$composer
|
||
);
|
||
}
|
||
|
||
private static function newButton(array $extra = []): array
|
||
{
|
||
return ['items' => [['module' => 'button', 'name' => 'CTA dunkel',
|
||
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#111111']]]] + $extra]];
|
||
}
|
||
|
||
public function testDryRunThenConfirmCreatesThePreset(): void
|
||
{
|
||
$ability = $this->ability();
|
||
|
||
$dry = self::assertToolError($ability->execute(self::newButton()), 'confirm_required');
|
||
self::assertSame('added', $dry['diff'][0]['action']);
|
||
self::assertStringContainsString('Presets: 1 angelegt.', $dry['message']);
|
||
|
||
$this->snapshots->expects('save')->andReturn('presets-1');
|
||
$this->snapshots->expects('attachLog');
|
||
$result = $ability->execute(self::newButton() + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame('presets-1', $result['snapshot_id']);
|
||
$names = array_column(PresetBook::fromDivi($this->stored)->items(new PresetScope('module', 'divi/button')), 'name');
|
||
self::assertContains('CTA dunkel', $names);
|
||
}
|
||
|
||
public function testExistingNameIsRefusedForANewPreset(): void
|
||
{
|
||
$result = self::assertToolError($this->ability()->execute(['items' => [
|
||
['module' => 'button', 'name' => 'CTA primär'],
|
||
]]), 'validation_failed');
|
||
|
||
self::assertStringContainsString('aaaaaaaaaa', $result['fields']['items[0].name']);
|
||
}
|
||
|
||
public function testChangingAndMakingDefault(): void
|
||
{
|
||
$dry = self::assertToolError($this->ability()->execute(['items' => [[
|
||
'module' => 'button', 'preset' => 'CTA primär', 'make_default' => true,
|
||
'attrs' => ['button.decoration.background' => ['desktop' => ['color' => '#222222']]],
|
||
]]]), 'confirm_required');
|
||
|
||
self::assertSame(['changed', 'default_set'], array_column($dry['diff'], 'action'));
|
||
}
|
||
|
||
public function testUnknownPresetAndBadItemsAreCollected(): void
|
||
{
|
||
$result = self::assertToolError($this->ability()->execute(['items' => [
|
||
['module' => 'button', 'preset' => 'Fehlt'],
|
||
['name' => 'ohne Modul'],
|
||
]]), 'validation_failed');
|
||
|
||
self::assertArrayHasKey('items[0].preset', $result['fields']);
|
||
self::assertArrayHasKey('items[1]', $result['fields']);
|
||
}
|
||
|
||
public function testEditorWithoutUnfilteredHtmlIsRefusedForRawCss(): void
|
||
{
|
||
$result = $this->ability(false)->execute(['items' => [[
|
||
'module' => 'button', 'name' => 'Mit CSS',
|
||
'attrs' => ['css' => ['desktop' => ['mainElement' => 'color:red']]],
|
||
]]]);
|
||
|
||
self::assertNotTrue(is_array($result) && ($result['ok'] ?? false));
|
||
self::assertContains(Errors::codeOf($result), ['validation_failed', 'invalid_attr_path']);
|
||
}
|
||
|
||
public function testFromNodeReadsThePage(): void
|
||
{
|
||
$loader = Mockery::mock(DiviPageLoader::class);
|
||
$content = '<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||
. '<!-- wp:divi/button {"button":{"decoration":{"background":{"desktop":{"value":{"color":"#abcdef"}}}}}} /-->'
|
||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->';
|
||
$document = (new Parser())->parse($content);
|
||
$page = new DiviPage(DiviFixtures::givenPost($content), $document, NodeIndex::build($document));
|
||
$loader->expects('load')->with(12, false)->andReturn($page);
|
||
|
||
$dry = self::assertToolError($this->ability(true, null, $loader)->execute(['items' => [[
|
||
'module' => 'button', 'name' => 'Aus Seite', 'from_node' => ['post_id' => 12, 'id' => 's0.r0.c0.m0'],
|
||
]]]), 'confirm_required');
|
||
|
||
self::assertSame('added', $dry['diff'][0]['action']);
|
||
}
|
||
|
||
public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
|
||
self::assertToolError($this->ability()->permission(self::newButton()), 'forbidden');
|
||
}
|
||
|
||
public function testSchemaAndMeta(): void
|
||
{
|
||
$ability = $this->ability();
|
||
|
||
self::assertSame('lit/divi-presets-set', $ability->name());
|
||
self::assertFalse($ability->isDirect());
|
||
self::assertSame(DiviPresetsSetAbility::MAX_ITEMS, $ability->inputSchema()['properties']['items']['maxItems']);
|
||
self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2));
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviFixtures::givenPost(string $content, array $fields = []): WP_Post` baut den Beitrag ohne WordPress.
|
||
|
||
Die Pfad-ID des Buttons im Testinhalt ist `s0.r0.c0.m0` (`NodeIndex::ID_PATTERN`: `s` Abschnitt, `r` Zeile, `c` Spalte, `m` Modul).
|
||
|
||
- [ ] **Step 5: Test laufen lassen – er scheitert**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php`
|
||
Expected: FAIL, Klasse fehlt.
|
||
|
||
- [ ] **Step 6: Ability implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use Closure;
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/divi-presets-set (spec §6.3, G7): creates and changes module and option group presets – from the short and full
|
||
* form, from a module of a page (from_node), with nested group presets and as the default preset – through the
|
||
* change pipeline (dry run, design_rev, snapshot, Divi's sync, change log). New names must be free within the
|
||
* module/group (A5). The dry run lists the posts that use a changed preset or reach a new default.
|
||
*/
|
||
final class DiviPresetsSetAbility implements Ability
|
||
{
|
||
public const MAX_ITEMS = 20;
|
||
|
||
private const GROUP_DEFAULT_WARNING = 'Standard-Gruppen-Presets wirken zusätzlich auf alle Module mit dieser '
|
||
. 'Gruppe ohne eigenes Gruppen-Preset; diese Seiten sind nicht aufgelistet.';
|
||
|
||
/** @param Closure(): PresetComposer $composer */
|
||
public function __construct(
|
||
private DesignSystemWriter $writer,
|
||
private DesignChange $change,
|
||
private GlobalUsages $usages,
|
||
private DiviPageLoader $loader,
|
||
private Closure $composer
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/divi-presets-set';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Divi-Presets anlegen und ändern';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Legt Modul-Presets (module) und Gruppen-Presets (group) an oder ändert sie (preset = Name oder ID). '
|
||
. 'Einstellungen in attrs (Vollform wie bei lit-divi-update, mit Platzhaltern {{color:…}}/{{var:…}}) oder '
|
||
. 'aus einem Modul einer Seite (from_node: post_id + Pfad-ID); nur Gestaltung, keine Texte oder Links. '
|
||
. 'group_presets verschachtelt Gruppen-Presets, make_default macht das Preset zum Standard. Wirkt auf alle '
|
||
. 'Seiten: zuerst Probelauf (diff, betroffene Seiten, 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' => [
|
||
'module' => ['type' => 'string'],
|
||
'group' => ['type' => 'string'],
|
||
'preset' => ['type' => 'string', 'description' => 'Zu änderndes Preset (Name oder ID).'],
|
||
'name' => ['type' => 'string', 'description' => 'Name eines neuen Presets.'],
|
||
'rename' => ['type' => 'string'],
|
||
'set' => ['type' => 'object'],
|
||
'attrs' => ['type' => 'object'],
|
||
'from_node' => [
|
||
'type' => 'object',
|
||
'properties' => ['post_id' => ['type' => 'integer'], 'id' => ['type' => 'string']],
|
||
'required' => ['post_id', 'id'],
|
||
],
|
||
'source_module' => ['type' => 'string', 'description' => 'Neues Gruppen-Preset: Modul.'],
|
||
'group_id' => ['type' => 'string'],
|
||
'group_presets' => ['type' => 'object'],
|
||
'make_default' => ['type' => 'boolean'],
|
||
],
|
||
],
|
||
],
|
||
] + 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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
$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 PresetArea($this->writer),
|
||
$input,
|
||
fn (array $raw): DesignPlan|WP_Error => $this->plan(PresetBook::fromDivi($raw), $items)
|
||
);
|
||
}
|
||
|
||
/** @param list<mixed> $items */
|
||
private function plan(PresetBook $before, array $items): DesignPlan|WP_Error
|
||
{
|
||
$composer = ($this->composer)();
|
||
$book = $before;
|
||
$errors = [];
|
||
$invalid = [];
|
||
$targets = [];
|
||
$warnings = [];
|
||
foreach ($items as $index => $item) {
|
||
$field = sprintf('items[%d]', $index);
|
||
if (! is_array($item)) {
|
||
$errors[$field] = 'Objekt erwartet';
|
||
continue;
|
||
}
|
||
$prepared = $this->prepare($book, $item, $field, $errors);
|
||
if ($prepared === null) {
|
||
continue;
|
||
}
|
||
[$scope, $existing, $source] = $prepared;
|
||
$result = $composer->compose($book, $scope, $existing, $item, $field, $source);
|
||
$invalid = array_merge($invalid, $result['invalid_paths']);
|
||
if ($result['errors'] !== [] || $result['invalid_paths'] !== []) {
|
||
$errors += $result['errors'];
|
||
continue;
|
||
}
|
||
$id = (string) $result['item']['id'];
|
||
$book = $book->with($scope, $result['item']);
|
||
if (($item['make_default'] ?? null) === true) {
|
||
$book = $book->withDefault($scope, $id);
|
||
if ($scope->kind === PresetScope::GROUP) {
|
||
$warnings[] = self::GROUP_DEFAULT_WARNING;
|
||
}
|
||
} elseif (($item['make_default'] ?? null) === false && $book->defaultId($scope) === $id) {
|
||
$book = $book->withDefault($scope, '');
|
||
}
|
||
if ($existing !== null || $book->defaultId($scope) === $id) {
|
||
$targets[] = ['scope' => $scope, 'id' => $id, 'default' => $before->defaultId($scope) === $id
|
||
|| $book->defaultId($scope) === $id];
|
||
}
|
||
}
|
||
|
||
if ($invalid !== []) {
|
||
return Errors::invalidAttrPath($errors);
|
||
}
|
||
if ($errors !== []) {
|
||
return Errors::invalidInput('Ungültige Preset-Angaben; nichts wurde gespeichert.', $errors);
|
||
}
|
||
|
||
$diff = PresetDiff::between($before, $book);
|
||
|
||
return new DesignPlan(
|
||
$book->data(),
|
||
$diff,
|
||
PresetDiff::summary($diff),
|
||
$targets === [] ? null : $this->usages->reportFor(new PresetUsageQuery($targets)),
|
||
array_values(array_unique($warnings))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Scope, preset to change and source node of one item; null after noting the problem in $errors.
|
||
*
|
||
* @param array<array-key, mixed> $item
|
||
* @param array<string, string> $errors
|
||
* @return array{PresetScope, array<string, mixed>|null, Block|null}|null
|
||
*/
|
||
private function prepare(PresetBook $book, array $item, string $field, array &$errors): ?array
|
||
{
|
||
try {
|
||
$scope = PresetScope::fromInput($item, $field);
|
||
} catch (InvalidArgumentException $e) {
|
||
[$path, $problem] = explode(': ', $e->getMessage(), 2) + [1 => ''];
|
||
$errors[$path] = $problem;
|
||
|
||
return null;
|
||
}
|
||
|
||
$existing = null;
|
||
try {
|
||
if (is_string($item['preset'] ?? null)) {
|
||
$existing = $book->find($scope, $item['preset']);
|
||
if ($existing === null) {
|
||
$errors[$field . '.preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), $item['preset']);
|
||
|
||
return null;
|
||
}
|
||
}
|
||
} catch (InvalidArgumentException $e) {
|
||
$errors[$field . '.preset'] = $e->getMessage();
|
||
|
||
return null;
|
||
}
|
||
|
||
foreach (['name' => $existing === null, 'rename' => $existing !== null] as $key => $applies) {
|
||
if (! $applies || ! is_string($item[$key] ?? null)) {
|
||
continue;
|
||
}
|
||
$taken = array_diff($book->idsNamed($scope, $item[$key]), [(string) ($existing['id'] ?? '')]);
|
||
if ($taken !== []) {
|
||
$errors[$field . '.' . $key] = sprintf(
|
||
'Name schon vergeben (%s); zum Ändern preset angeben oder einen anderen Namen wählen',
|
||
implode(', ', $taken)
|
||
);
|
||
|
||
return null;
|
||
}
|
||
}
|
||
|
||
$source = $this->source($scope, $item, $field, $errors);
|
||
if ($source === false) {
|
||
return null;
|
||
}
|
||
|
||
return [$scope, $existing, $source];
|
||
}
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $item
|
||
* @param array<string, string> $errors
|
||
*/
|
||
private function source(PresetScope $scope, array $item, string $field, array &$errors): Block|false|null
|
||
{
|
||
$from = $item['from_node'] ?? null;
|
||
if ($from === null) {
|
||
return null;
|
||
}
|
||
if (! is_array($from) || ! is_string($from['id'] ?? null)) {
|
||
$errors[$field . '.from_node'] = 'post_id und id (Pfad-ID aus lit-divi-outline) erwartet';
|
||
|
||
return false;
|
||
}
|
||
|
||
$page = $this->loader->load($from['post_id'] ?? null, false);
|
||
if ($page instanceof WP_Error) {
|
||
$errors[$field . '.from_node.post_id'] = Errors::clientMessage($page);
|
||
|
||
return false;
|
||
}
|
||
$block = $page->index->find($from['id']);
|
||
if ($block === null) {
|
||
$errors[$field . '.from_node.id'] = sprintf('unbekannte Pfad-ID %s (lit-divi-outline)', $from['id']);
|
||
|
||
return false;
|
||
}
|
||
if ($scope->kind === PresetScope::MODULE && $block->name() !== $scope->name) {
|
||
$errors[$field . '.from_node.id'] = sprintf('%s ist %s, nicht %s', $from['id'], $block->name(), $scope->name);
|
||
|
||
return false;
|
||
}
|
||
|
||
return $block;
|
||
}
|
||
}
|
||
```
|
||
|
||
`false` steht für „Fehler schon in `$errors` notiert“, `null` für „kein `from_node`“ (`Block|false|null` ist ab PHP 8.0 gültig).
|
||
|
||
In `DiviIntegration::abilities()` ergänzen:
|
||
|
||
```php
|
||
new DiviPresetsSetAbility(
|
||
$this->designWriter,
|
||
$this->designChange(),
|
||
$this->globalUsages(),
|
||
$loader,
|
||
$this->presetComposer(...)
|
||
),
|
||
```
|
||
|
||
und die Helfer:
|
||
|
||
```php
|
||
private function designChange(): DesignChange
|
||
{
|
||
return new DesignChange(new SnapshotStore(), $this->log);
|
||
}
|
||
|
||
private function presetComposer(): PresetComposer
|
||
{
|
||
$composition = new DiviComposition($this->modules, $this->design);
|
||
|
||
return new PresetComposer(
|
||
$composition->composer(),
|
||
$composition->storedGuard(),
|
||
$this->designWriter->presetAttrs(...),
|
||
$this->designWriter->groupsOf(...),
|
||
$this->modules->builderVersion(),
|
||
static fn (): int => (int) floor(microtime(true) * 1000)
|
||
);
|
||
}
|
||
```
|
||
|
||
Namensliste in `DiviIntegrationTest` um `'lit/divi-presets-set',` ergänzen.
|
||
|
||
- [ ] **Step 7: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design tests/Unit/Integrations/Divi/DiviIntegrationTest.php tests/Unit/Guides/ToolNamingTest.php`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 8: Gesamtlauf, Lint, Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DesignRights.php src/Integrations/Divi/Design/DiviPresetsSetAbility.php src/Integrations/Divi/Design/PresetDiff.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/DesignRightsTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php tests/Unit/Integrations/Divi/Design/PresetDiffTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php
|
||
git commit -m "feat: lit/divi-presets-set – Presets anlegen und ändern mit Probelauf und Snapshot" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: `lit/divi-presets-delete` – unbenutzte Presets löschen
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php`
|
||
- Modify: `src/Abilities/Errors.php` (`inUse()` mit eigenem Hinweis)
|
||
- Modify: `tests/Unit/Abilities/ErrorsTest.php`
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: wie Task 11, dazu `PresetBook::without()`, `PresetBook::nestedUsers()`, `UsageReport::exists()/listing()`.
|
||
- Produces:
|
||
- `Errors::inUse(string $message, array $usages, ?string $hint = null): WP_Error` – ohne `$hint` unverändert („… nur mit force: true …“, für `lit/media-delete`).
|
||
- `final class DiviPresetsDeleteAbility implements Ability { public const MAX_ITEMS = 20; public function __construct(DesignSystemWriter $writer, DesignChange $change, GlobalUsages $usages) }`.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
In `tests/Unit/Abilities/ErrorsTest.php` anhängen:
|
||
|
||
```php
|
||
public function testInUseTakesItsOwnHint(): void
|
||
{
|
||
$error = Errors::inUse('Wird verwendet.', [['post_id' => 3]], 'Erst die Verweise entfernen.');
|
||
|
||
$payload = self::assertToolError($error, 'in_use');
|
||
self::assertSame('Erst die Verweise entfernen.', $payload['hint']);
|
||
self::assertStringContainsString('force: true', Errors::inUse('x', [])->get_error_data()['hint']);
|
||
}
|
||
```
|
||
|
||
(`ErrorsTest` erbt von `LIT\MCP\Tests\Unit\TestCase` und hat damit `assertToolError()`.)
|
||
|
||
`tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class DiviPresetsDeleteAbilityTest extends TestCase
|
||
{
|
||
private function ability(UsageReport $report, ?DesignSystemWriter $writer = null, ?SnapshotStore $snapshots = null): DiviPresetsDeleteAbility
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
if ($writer === null) {
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('presets')->andReturn(PresetBookTest::stored());
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
}
|
||
$usages = Mockery::mock(GlobalUsages::class);
|
||
$usages->allows('reportFor')->andReturn($report);
|
||
|
||
return new DiviPresetsDeleteAbility(
|
||
$writer,
|
||
new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)),
|
||
$usages
|
||
);
|
||
}
|
||
|
||
private static function unused(): UsageReport
|
||
{
|
||
return new UsageReport(0, [], 0, 0, [], 0, false);
|
||
}
|
||
|
||
public function testUnusedPresetIsDeletedAfterConfirm(): void
|
||
{
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('presets')->andReturn(PresetBookTest::stored());
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$writer->expects('savePresets')
|
||
->with(Mockery::on(static function (array $rest): bool {
|
||
$ids = array_column($rest['module'][0]['items'], 'id');
|
||
|
||
return $ids === ['aaaaaaaaaa', 'cccccccccc'];
|
||
}), true)
|
||
->andReturnUsing(static function (): array {
|
||
$stored = PresetBookTest::stored();
|
||
unset($stored['module']['divi/button']['items']['bbbbbbbbbb']);
|
||
|
||
return $stored;
|
||
});
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->expects('save')->andReturn('presets-9');
|
||
$snapshots->expects('attachLog');
|
||
$ability = $this->ability(self::unused(), $writer, $snapshots);
|
||
$input = ['items' => [['module' => 'button', 'preset' => 'bbbbbbbbbb']]];
|
||
|
||
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
|
||
self::assertSame('removed', $dry['diff'][0]['action']);
|
||
|
||
$result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||
self::assertSame('presets-9', $result['snapshot_id']);
|
||
}
|
||
|
||
public function testPresetInUseIsRefused(): void
|
||
{
|
||
$used = new UsageReport(2, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']],
|
||
1, 1, [], 0, false);
|
||
|
||
$error = self::assertToolError(
|
||
$this->ability($used)->execute(['items' => [['module' => 'button', 'preset' => 'CTA primär']]]),
|
||
'in_use'
|
||
);
|
||
|
||
self::assertSame(3, $error['usages'][0]['post_id']);
|
||
self::assertStringContainsString('lit/divi-design-detach', $error['hint']);
|
||
}
|
||
|
||
public function testNestedUseInAnotherPresetCounts(): void
|
||
{
|
||
$error = self::assertToolError(
|
||
$this->ability(self::unused())->execute(['items' => [['group' => 'font-body', 'preset' => 'Lesetext']]]),
|
||
'in_use'
|
||
);
|
||
|
||
self::assertSame('preset', $error['usages'][0]['type']);
|
||
self::assertSame('dddddddddd', $error['usages'][0]['preset_id']);
|
||
}
|
||
|
||
public function testDeletingTheDefaultClearsIt(): void
|
||
{
|
||
$dry = self::assertToolError(
|
||
$this->ability(self::unused())->execute(['items' => [['module' => 'text', 'preset' => 'Fließtext']]]),
|
||
'confirm_required'
|
||
);
|
||
|
||
self::assertSame(['removed', 'default_cleared'], array_column($dry['diff'], 'action'));
|
||
}
|
||
|
||
public function testUnknownPreset(): void
|
||
{
|
||
self::assertToolError(
|
||
$this->ability(self::unused())->execute(['items' => [['module' => 'button', 'preset' => 'Fehlt']]]),
|
||
'validation_failed'
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – sie scheitern**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Abilities/ErrorsTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: `Errors::inUse()` erweitern**
|
||
|
||
```php
|
||
/**
|
||
* @param list<array<string, mixed>> $usages
|
||
* @param string|null $hint Replaces the default hint (force: true, for lit/media-delete).
|
||
*/
|
||
public static function inUse(string $message, array $usages, ?string $hint = null): WP_Error
|
||
{
|
||
return self::make(
|
||
'in_use',
|
||
$message,
|
||
$hint ?? 'Verwendungen prüfen; nur mit force: true trotzdem ausführen.',
|
||
409,
|
||
['usages' => $usages]
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Ability implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/divi-presets-delete (spec §6.4, G5): deletes presets nothing uses any more – no page, library entry or Theme
|
||
* Builder layout (GlobalUsages with PresetUsageQuery), and no other preset that nests it. A used preset is refused
|
||
* with its usages (in_use); lit/divi-design-detach (Etappe 4b) removes the references first. A default preset loses
|
||
* its default. Through the change pipeline with Divi's delete semantics (legacy option cleaned).
|
||
*/
|
||
final class DiviPresetsDeleteAbility implements Ability
|
||
{
|
||
public const MAX_ITEMS = 20;
|
||
|
||
private const DETACH_HINT = 'Erst die Verweise entfernen: lit/divi-design-detach (ab Etappe 4b) oder im '
|
||
. 'Divi-Builder; danach erneut löschen.';
|
||
|
||
public function __construct(
|
||
private DesignSystemWriter $writer,
|
||
private DesignChange $change,
|
||
private GlobalUsages $usages
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/divi-presets-delete';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Divi-Presets löschen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Löscht Modul- oder Gruppen-Presets (items: module bzw. group + preset als Name oder ID), die nirgends '
|
||
. 'mehr verwendet werden; sonst Fehler in_use mit den Fundstellen. Zuerst Probelauf, 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' => [
|
||
'module' => ['type' => 'string'],
|
||
'group' => ['type' => 'string'],
|
||
'preset' => ['type' => 'string'],
|
||
],
|
||
'required' => ['preset'],
|
||
],
|
||
],
|
||
] + DesignChange::inputProperties(),
|
||
'required' => ['items'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return DesignChange::outputSchema();
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::destructive(true);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
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;
|
||
}
|
||
$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 gelöscht.',
|
||
['items' => sprintf('1 bis %d Einträge', self::MAX_ITEMS)]
|
||
);
|
||
}
|
||
|
||
return $this->change->run(
|
||
$this->name(),
|
||
new PresetArea($this->writer),
|
||
$input,
|
||
fn (array $raw): DesignPlan|WP_Error => $this->plan(PresetBook::fromDivi($raw), $items)
|
||
);
|
||
}
|
||
|
||
/** @param list<mixed> $items */
|
||
private function plan(PresetBook $before, array $items): DesignPlan|WP_Error
|
||
{
|
||
$book = $before;
|
||
$errors = [];
|
||
$targets = [];
|
||
foreach ($items as $index => $item) {
|
||
$field = sprintf('items[%d]', $index);
|
||
try {
|
||
$scope = PresetScope::fromInput(is_array($item) ? $item : [], $field);
|
||
$found = $before->find($scope, is_string($item['preset'] ?? null) ? $item['preset'] : '');
|
||
} catch (InvalidArgumentException $e) {
|
||
$errors[$field] = $e->getMessage();
|
||
continue;
|
||
}
|
||
if ($found === null) {
|
||
$errors[$field . '.preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), (string) $item['preset']);
|
||
continue;
|
||
}
|
||
$id = (string) $found['id'];
|
||
$targets[] = ['scope' => $scope, 'id' => $id, 'default' => $before->defaultId($scope) === $id];
|
||
$book = $book->without($scope, $id);
|
||
}
|
||
if ($errors !== []) {
|
||
return Errors::invalidInput('Ungültige Angaben; nichts wurde gelöscht.', $errors);
|
||
}
|
||
|
||
$deleted = array_column($targets, 'id');
|
||
$nested = [];
|
||
foreach ($targets as $target) {
|
||
foreach ($before->nestedUsers($target['id']) as $user) {
|
||
if (! in_array($user['id'], $deleted, true)) {
|
||
$nested[] = ['type' => 'preset', 'preset_id' => $user['id'], 'name' => $user['name'],
|
||
'scope' => $user['scope']->name];
|
||
}
|
||
}
|
||
}
|
||
$report = $this->usages->reportFor(new PresetUsageQuery($targets), false);
|
||
if ($nested !== [] || $report->exists()) {
|
||
return Errors::inUse(
|
||
'Die Presets werden noch verwendet; nichts wurde gelöscht.',
|
||
array_merge($nested, $report->listing()['affected']),
|
||
self::DETACH_HINT
|
||
);
|
||
}
|
||
|
||
$diff = PresetDiff::between($before, $book);
|
||
|
||
return new DesignPlan($book->data(), $diff, PresetDiff::summary($diff), null, [], true);
|
||
}
|
||
}
|
||
```
|
||
|
||
Bei `PresetScope::fromInput()`-Fehlern steht der Pfad schon in der Meldung (`items[0]: …`); hier wird die ganze Meldung unter `items[i]` abgelegt – im Test reicht der Code `validation_failed`.
|
||
|
||
In `DiviIntegration::abilities()` ergänzen: `new DiviPresetsDeleteAbility($this->designWriter, $this->designChange(), $this->globalUsages()),`; Namensliste in `DiviIntegrationTest` um `'lit/divi-presets-delete',`.
|
||
|
||
Der Hinweis nennt `lit/divi-design-detach`, das erst mit 4b registriert wird; `ToolNamingTest` erlaubt das (er prüft nur die Schreibweise direkter und indirekter Tools).
|
||
|
||
- [ ] **Step 5: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Abilities/ErrorsTest.php tests/Unit/Integrations/Divi tests/Unit/Guides/ToolNamingTest.php`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Gesamtlauf, Lint, Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
|
||
```bash
|
||
git add src/Abilities/Errors.php src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Abilities/ErrorsTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php
|
||
git commit -m "feat: lit/divi-presets-delete – unbenutzte Presets löschen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: `lit/divi-design-snapshots` und `lit/divi-design-restore`
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php`
|
||
- Create: `src/Integrations/Divi/Design/DiviDesignRestoreAbility.php`
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/DiviDesignSnapshotsAbilityTest.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `SnapshotStore::list()/get()`, `Cursor::inputProperties()/decode()/limit()/page()`, `DesignChange::run()`, `PresetArea`, `PresetBook`, `PresetDiff`, `DesignRights::presets()`.
|
||
- Produces:
|
||
- `final class DiviDesignSnapshotsAbility implements Ability { public function __construct(SnapshotStore $snapshots) }` – Name `lit/divi-design-snapshots`, Eingabe `area` (Standard `presets`), `cursor`, `limit`; Ausgabe `{area, snapshots, next_cursor}`; Recht `edit_theme_options`.
|
||
- `final class DiviDesignRestoreAbility implements Ability { public function __construct(DesignSystemWriter $writer, DesignChange $change, SnapshotStore $snapshots) }` – Name `lit/divi-design-restore`, Eingabe `snapshot_id`, `confirm`, `design_rev`. In 4a nur Bereich `presets`; andere Bereiche → `unsupported` („ab Etappe 4c/4d“).
|
||
|
||
`Cursor` (vorhanden): `decode(?string): ?int` (0 ohne Cursor, `null` = ungültig), `limit(mixed): int` (Standard 20, höchstens 100), `page(array $rows, int $limit, int $offset): array{items, next_cursor}` – `$rows` beginnen beim Offset (der Aufrufer schneidet ab), `next_cursor` entsteht, wenn mehr als `$limit` Zeilen übergeben werden.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Design/DiviDesignSnapshotsAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class DiviDesignSnapshotsAbilityTest extends TestCase
|
||
{
|
||
public function testListsTheSnapshotsOfAnArea(): void
|
||
{
|
||
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
|
||
$store = Mockery::mock(SnapshotStore::class);
|
||
$store->expects('list')->with('presets')->andReturn([
|
||
['id' => 'presets-2', 'created' => '2026-10-16T10:00:00+00:00', 'user_id' => 1,
|
||
'ability' => 'lit/divi-presets-set', 'log_id' => 4, 'size' => 120],
|
||
]);
|
||
$ability = new DiviDesignSnapshotsAbility($store);
|
||
|
||
self::assertTrue($ability->permission([]));
|
||
$result = $ability->execute([]);
|
||
|
||
self::assertSame('presets', $result['area']);
|
||
self::assertSame('presets-2', $result['snapshots'][0]['id']);
|
||
self::assertTrue($ability->annotations()['readonly']);
|
||
}
|
||
|
||
public function testUnknownAreaAndRights(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
$ability = new DiviDesignSnapshotsAbility(Mockery::mock(SnapshotStore::class));
|
||
|
||
self::assertToolError($ability->permission([]), 'forbidden');
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
self::assertToolError($ability->execute(['area' => 'seiten']), 'validation_failed');
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class DiviDesignRestoreAbilityTest extends TestCase
|
||
{
|
||
public function testRestoresWithDryRunAndASnapshotOfTheCurrentState(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$older = PresetBookTest::stored();
|
||
unset($older['module']['divi/button']['items']['bbbbbbbbbb']);
|
||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||
$writer->allows('presets')->andReturn(PresetBookTest::stored());
|
||
$writer->allows('canUseVisualBuilder')->andReturn(true);
|
||
$writer->expects('savePresets')->with(Mockery::type('array'), true)->andReturn($older);
|
||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||
$snapshots->allows('get')->with('presets', 'presets-1')->andReturn(['id' => 'presets-1', 'data' => $older,
|
||
'created' => 'x', 'user_id' => 1, 'ability' => 'lit/divi-presets-set', 'log_id' => 2, 'size' => 1]);
|
||
$snapshots->expects('save')->with('presets', Mockery::type('array'), 'lit/divi-design-restore')->andReturn('presets-3');
|
||
$snapshots->expects('attachLog');
|
||
$ability = new DiviDesignRestoreAbility($writer, new DesignChange($snapshots, new FakeChangeLog(8)), $snapshots);
|
||
|
||
$dry = self::assertToolError($ability->execute(['snapshot_id' => 'presets-1']), 'confirm_required');
|
||
self::assertSame('removed', $dry['diff'][0]['action']);
|
||
|
||
$result = $ability->execute(['snapshot_id' => 'presets-1', 'confirm' => true, 'design_rev' => $dry['design_rev']]);
|
||
self::assertSame('presets-3', $result['snapshot_id']);
|
||
}
|
||
|
||
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($writer, new DesignChange($snapshots, FakeChangeLog::forbidden()), $snapshots);
|
||
|
||
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');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – sie scheitern**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DiviDesignSnapshotsAbilityTest.php tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php`
|
||
Expected: FAIL, Klassen fehlen.
|
||
|
||
- [ ] **Step 3: `DiviDesignSnapshotsAbility` implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Cursor;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/** lit/divi-design-snapshots (spec §5): the snapshots of one design area, newest first, without their content. */
|
||
final class DiviDesignSnapshotsAbility implements Ability
|
||
{
|
||
public function __construct(private SnapshotStore $snapshots)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/divi-design-snapshots';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Snapshots des Designsystems';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Listet die Snapshots eines Bereichs des Designsystems (area: presets, colors, variables, '
|
||
. 'theme_options; Standard presets), neueste zuerst, mit Zeitpunkt, Tool und Protokolleintrag. '
|
||
. 'Wiederherstellen mit lit/divi-design-restore.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'area' => ['type' => 'string', 'enum' => SnapshotStore::AREAS],
|
||
] + Cursor::inputProperties(),
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'area' => ['type' => 'string'],
|
||
'snapshots' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'next_cursor' => ['type' => ['string', 'null']],
|
||
],
|
||
'required' => ['area', 'snapshots', 'next_cursor'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::read();
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return current_user_can('edit_theme_options')
|
||
? true
|
||
: Errors::forbidden('Snapshots ansehen erfordert das Recht, das Design anzupassen (edit_theme_options).');
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$area = $input['area'] ?? 'presets';
|
||
if (! in_array($area, SnapshotStore::AREAS, true)) {
|
||
return Errors::invalidInput('Unbekannter Bereich.', ['area' => implode(', ', SnapshotStore::AREAS)]);
|
||
}
|
||
$offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
|
||
if ($offset === null) {
|
||
return Cursor::invalid();
|
||
}
|
||
$limit = Cursor::limit($input['limit'] ?? null);
|
||
$page = Cursor::page(array_slice($this->snapshots->list($area), $offset, $limit + 1), $limit, $offset);
|
||
|
||
return ['area' => $area, 'snapshots' => $page['items'], 'next_cursor' => $page['next_cursor']];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: `DiviDesignRestoreAbility` implementieren**
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/divi-design-restore (spec §5, G6): writes a snapshot back through the change pipeline – dry run with the diff
|
||
* to the current state, confirm with design_rev, and first a snapshot of the current state, so a restore can be
|
||
* undone as well. Etappe 4a restores presets; the other areas follow with their tools (4c, 4d).
|
||
*/
|
||
final class DiviDesignRestoreAbility implements Ability
|
||
{
|
||
private const ID = '/^(colors|variables|presets|theme_options)-\d{8}T\d{6}Z-[0-9a-f]{6}$/D';
|
||
|
||
public function __construct(
|
||
private DesignSystemWriter $writer,
|
||
private DesignChange $change,
|
||
private SnapshotStore $snapshots
|
||
) {
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
$id = is_string($input['snapshot_id'] ?? null) ? $input['snapshot_id'] : '';
|
||
if (preg_match(self::ID, $id, $match) !== 1) {
|
||
return Errors::invalidInput('Ungültige Snapshot-ID.', ['snapshot_id' => 'z. B. presets-20261016T101500Z-a1b2c3']);
|
||
}
|
||
if ($match[1] !== 'presets') {
|
||
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('presets', $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(),
|
||
new PresetArea($this->writer),
|
||
$input,
|
||
static function (array $raw) use ($snapshot, $id): DesignPlan {
|
||
$before = PresetBook::fromDivi($raw);
|
||
$after = PresetBook::fromDivi($snapshot['data']);
|
||
$diff = PresetDiff::between($before, $after);
|
||
|
||
return new DesignPlan(
|
||
$after->data(),
|
||
$diff,
|
||
sprintf('Snapshot %s wiederherstellen – %s', $id, 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
|
||
);
|
||
}
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
In `DiviIntegration::abilities()` ergänzen:
|
||
|
||
```php
|
||
new DiviDesignSnapshotsAbility(new SnapshotStore()),
|
||
new DiviDesignRestoreAbility($this->designWriter, $this->designChange(), new SnapshotStore()),
|
||
```
|
||
|
||
Namensliste in `DiviIntegrationTest` um `'lit/divi-design-snapshots', 'lit/divi-design-restore',` ergänzen.
|
||
|
||
- [ ] **Step 5: Tests laufen lassen – sie bestehen**
|
||
|
||
Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi tests/Unit/Guides/ToolNamingTest.php`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Gesamtlauf, Lint, Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php src/Integrations/Divi/Design/DiviDesignRestoreAbility.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/DiviDesignSnapshotsAbilityTest.php tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php
|
||
git commit -m "feat: lit/divi-design-snapshots und lit/divi-design-restore (Bereich presets)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Guide, Ende-zu-Ende-Test gegen echtes Divi, Entscheidungsnotiz
|
||
|
||
**Files:**
|
||
- Modify: `docs/guides/divi-design-system.md`
|
||
- Create: `tests/Integration/divi-presets-smoke.sh`
|
||
- Modify: `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: alle Tools aus Tasks 10–13; `tests/Integration/lib.sh` (`connect`, `ability`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`).
|
||
- Produces: Smoke `divi-presets-smoke.sh`, der grün endet mit `ALL CHECKS PASSED`.
|
||
|
||
- [ ] **Step 1: Guide ergänzen**
|
||
|
||
In `docs/guides/divi-design-system.md` nach den Regeln anhängen:
|
||
|
||
```markdown
|
||
## Presets anlegen, ändern, löschen
|
||
|
||
Über `mcp-adapter-execute-ability`:
|
||
|
||
- `lit/divi-preset-get` – ein Preset mit Einstellungen (`attrs` als Pfad → Wert wie bei `lit-divi-node-get`),
|
||
verschachtelten Gruppen-Presets und den Seiten, die es verwenden (`usage`).
|
||
- `lit/divi-presets-set` – `items` mit `module` (z. B. `"button"`) oder `group` (z. B. `"font-body"`):
|
||
- neu: `name` + Einstellungen; ändern: `preset` (Name oder ID), optional `rename`;
|
||
- Einstellungen in `attrs` (Vollform, Platzhalter `{{color:…}}`, `{{var:…}}`, `{{font:…}}`) oder aus einem
|
||
gestalteten Modul: `from_node` = `{"post_id": 12, "id": "s0.r0.c1.m2"}` (Pfad-ID aus `lit-divi-outline`);
|
||
- Presets tragen nur Gestaltung – Texte, Links und Bilder gehören ins Modul (sonst `validation_failed`);
|
||
- `group_presets` (nur Modul-Presets), z. B. `{"font": "Button-Schrift"}`; `make_default: true` macht das
|
||
Preset zum Standard seines Moduls bzw. seiner Gruppe;
|
||
- neue Gruppen-Presets brauchen das Modul, aus dem die Gruppe stammt: `source_module` oder `from_node`.
|
||
- `lit/divi-presets-delete` – nur Presets, die nirgends verwendet werden; sonst `in_use` mit den Fundstellen.
|
||
- `lit/divi-design-snapshots` / `lit/divi-design-restore` – vor jeder Änderung legt LIT MCP einen Snapshot an
|
||
(je Bereich die letzten 20); wiederherstellen mit `snapshot_id`.
|
||
|
||
Ablauf jeder Änderung: Aufruf ohne `confirm` = Probelauf (`confirm_required` mit `diff`, betroffenen Seiten und
|
||
`design_rev`), dem Nutzer die Änderung zeigen, dann derselbe Aufruf mit `confirm: true` und `design_rev`. Hat sich
|
||
der Bestand inzwischen geändert (z. B. im Divi-Builder), kommt `rev_mismatch` – neu prüfen.
|
||
|
||
Rechte: Presets ändern verlangt das Recht, das Design anzupassen, und in Divis Rollen-Editor den Visual Builder.
|
||
```
|
||
|
||
- [ ] **Step 2: Smoke-Skript schreiben**
|
||
|
||
`tests/Integration/divi-presets-smoke.sh`:
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# End-to-end checks of Etappe 4a (presets: read, create, change, default, delete, 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-presets-smoke.sh
|
||
# Creates presets named "LIT Smoke <run> …" and one draft page; the EXIT trap restores the preset inventory from the
|
||
# snapshot taken before the first change and deletes the page (with revisions) 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_PRESETS=$(wp eval 'echo wp_json_encode(ET\Builder\Packages\GlobalData\GlobalPreset::get_data());')
|
||
PAGE_ID=""
|
||
|
||
cleanup() {
|
||
wp eval '$d = json_decode(getenv("LIT_PRESETS"), true); et_update_option(ET\Builder\Packages\GlobalData\GlobalPreset::option_name(), $d, false, "", "", true);' \
|
||
--user="$ADMIN_LOGIN" >/dev/null 2>&1 || echo "WARN: preset inventory not restored"
|
||
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_PRESETS="$ORIGINAL_PRESETS"
|
||
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
|
||
|
||
connect "$ADMIN_LOGIN"
|
||
|
||
# 1. Create a button preset: dry run, then confirm.
|
||
NEW=$(jq -cn --arg n "$PREFIX CTA" '{items: [{module: "button", name: $n,
|
||
attrs: {"button.decoration.background": {desktop: {color: "#123456"}}}}]}')
|
||
DRY=$(ability lit/divi-presets-set "$NEW")
|
||
[ "$(error_code "$DRY")" = "confirm_required" ] || fail "create without confirm: $DRY"
|
||
REV=$(echo "$DRY" | jq -r '.__error | fromjson | .design_rev')
|
||
DONE=$(ability lit/divi-presets-set "$(echo "$NEW" | jq -c --arg r "$REV" '. + {confirm: true, design_rev: $r}')")
|
||
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "create with confirm: $DONE"
|
||
SNAPSHOT=$(echo "$DONE" | jq -r '.snapshot_id')
|
||
pass "preset created (snapshot $SNAPSHOT)"
|
||
|
||
# 2. Read it back through Divi and the tool.
|
||
GET=$(ability lit/divi-preset-get "$(jq -cn --arg p "$PREFIX CTA" '{module: "button", preset: $p}')")
|
||
ID=$(echo "$GET" | jq -r '.id')
|
||
[ "$(echo "$GET" | jq -r '.attrs["button.decoration.background.desktop.value.color"]')" = "#123456" ] \
|
||
|| fail "preset-get attrs: $GET"
|
||
wp eval "\$d = ET\Builder\Packages\GlobalData\GlobalPreset::get_data(); exit(isset(\$d['module']['divi/button']['items']['$ID']['styleAttrs']) ? 0 : 1);" \
|
||
|| fail "Divi does not store styleAttrs for $ID"
|
||
pass "preset readable, styleAttrs stored"
|
||
|
||
# 3. A page with the preset renders its class.
|
||
PAGE_ID=$(wp post create --post_type=page --post_status=draft --post_title="$PREFIX Seite" \
|
||
--post_content="<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column {\"module\":{\"decoration\":{\"sizing\":{\"desktop\":{\"value\":{\"flexType\":\"24_24\"}}}}}} --><!-- wp:divi/button {\"modulePreset\":[\"$ID\"],\"button\":{\"innerContent\":{\"desktop\":{\"value\":{\"text\":\"Los\"}}}}} /--><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->" \
|
||
--porcelain)
|
||
HTML=$(wp eval "echo apply_filters('the_content', get_post($PAGE_ID)->post_content);")
|
||
echo "$HTML" | grep -q "preset--module--divi-button--$ID" || fail "rendered page lacks the preset class"
|
||
pass "page renders preset class"
|
||
|
||
# 4. Deleting a used preset is refused.
|
||
DEL=$(jq -cn --arg p "$ID" '{items: [{module: "button", preset: $p}]}')
|
||
[ "$(error_code "$(ability lit/divi-presets-delete "$DEL")")" = "in_use" ] || fail "delete of a used preset"
|
||
pass "used preset not deletable"
|
||
|
||
# 5. Stale design_rev.
|
||
STALE=$(ability lit/divi-presets-set "$(echo "$NEW" | jq -c '.items[0].name += " 2" | . + {confirm: true, design_rev: "veraltet"}')")
|
||
[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale design_rev: $STALE"
|
||
pass "stale design_rev refused"
|
||
|
||
# 6. Restore the snapshot taken before step 1: the preset is gone again.
|
||
DRY=$(ability lit/divi-design-restore "$(jq -cn --arg s "$SNAPSHOT" '{snapshot_id: $s}')")
|
||
[ "$(error_code "$DRY")" = "confirm_required" ] || fail "restore dry run: $DRY"
|
||
REV=$(echo "$DRY" | jq -r '.__error | fromjson | .design_rev')
|
||
RESTORED=$(ability lit/divi-design-restore "$(jq -cn --arg s "$SNAPSHOT" --arg r "$REV" '{snapshot_id: $s, confirm: true, design_rev: $r}')")
|
||
[ "$(echo "$RESTORED" | jq -r '.ok')" = "true" ] || fail "restore: $RESTORED"
|
||
wp eval "\$d = ET\Builder\Packages\GlobalData\GlobalPreset::get_data(); exit(isset(\$d['module']['divi/button']['items']['$ID']) ? 1 : 0);" \
|
||
|| fail "restored inventory still has $ID"
|
||
pass "snapshot restored"
|
||
|
||
finish
|
||
```
|
||
|
||
Die Helfer `cleanup_app_passwords_for`, `guard_exit`, `pass`, `fail`, `finish` stammen aus `lib.sh`; der Trap folgt dem Muster aus `divi-library-smoke.sh` (`guard_exit` macht jedes Ende ohne `finish` zum Fehler). Der Preset-Bestand wird direkt in Divis Option zurückgeschrieben – unabhängig davon, ob das Plugin noch funktioniert.
|
||
|
||
- [ ] **Step 3: Smoke laufen lassen**
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-smoke.sh`
|
||
Expected: sechs `ok - …`-Zeilen und `ALL CHECKS PASSED`. Danach prüfen, dass der Preset-Bestand wieder dem Stand vor dem Lauf entspricht:
|
||
`ddev wp eval 'echo count(ET\Builder\Packages\GlobalData\GlobalPreset::get_data()["module"]["divi/button"]["items"] ?? []);'` (gleiche Zahl wie vor dem Lauf).
|
||
|
||
Scheitert Schritt 3 (Preset-Klasse fehlt), mit `ddev wp eval` die gerenderte Ausgabe ansehen und mit einer im Builder angelegten Fixture-Seite vergleichen (`[Fixture] Designsystem`, Seite 3182) – nicht den Test lockern.
|
||
|
||
- [ ] **Step 4: Entscheidungsnotiz nachführen**
|
||
|
||
In `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` am Ende ergänzen:
|
||
|
||
```markdown
|
||
## Umsetzung 4a (Plan 2026-10-16-etappe-4a-presets.md)
|
||
- Pipeline `DesignChange` (alle Bereiche), Snapshots `SnapshotStore`, Bereich `presets`.
|
||
- Tools: lit/divi-preset-get, lit/divi-presets-set, lit/divi-presets-delete, lit/divi-design-snapshots, lit/divi-design-restore (alle indirekt).
|
||
- Schreiben über die Schrittfolge von `GlobalPresetController::sync` (Mirror `preset-sync`).
|
||
- Offen für 4b: Fundstellen von Standard-Gruppen-Presets (Gruppenliste je Modul), lit/divi-design-detach.
|
||
```
|
||
|
||
- [ ] **Step 5: Gesamtlauf, Lint, Commit**
|
||
|
||
Run: `composer test && composer lint`
|
||
|
||
```bash
|
||
git add docs/guides/divi-design-system.md tests/Integration/divi-presets-smoke.sh docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md
|
||
git commit -m "test: Ende-zu-Ende-Test der Presets gegen Divi; Guide und Entscheidungsnotiz zu 4a" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Abschluss von 4a
|
||
|
||
- [ ] `composer test` und `composer lint` grün; alle bisherigen Smokes (`divi-write-smoke.sh`, `divi-library-smoke.sh`, `tb-smoke.sh`, `divi-release-smoke.sh`) grün.
|
||
- [ ] Review nach `superpowers:requesting-code-review` mit dem Review Focus oben; Clean-Code-Review (`anthropic-skills:clean-code-review`) über die neuen Klassen in `Divi/Design/`.
|
||
- [ ] Danach Plan 4b schreiben (Zuweisen an Seiten, Massenzuweisung, detach).
|