Files
WP-LIT-MCP/docs/superpowers/plans/2026-10-16-etappe-4a-presets.md

226 KiB
Raw Permalink Blame History

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:

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:

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:

## 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
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

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

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

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

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
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

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

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

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

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:

'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

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

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:

    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

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

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

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:

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).

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

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):

    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

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

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:
    /**
     * 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));
    }
  1. 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:
    /** @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(…);" …
    }
  1. candidates() nimmt die Suche:
    /** @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 …
    }
  1. 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

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

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

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

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

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

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

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

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

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

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

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

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

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 “.

  • 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()',):

                'GlobalPreset::get_group_preset_default_attr()', 'ModuleRegistration::get_module_settings()',

und einen Test anhängen:

    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:

                new Call(GlobalPreset::class . '::get_group_preset_default_attr', 1),
                new Call(ModuleRegistration::class . '::get_module_settings', 1),

und die Methode:

    /**
     * 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

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

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

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

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

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

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:

    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

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:

    /** 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

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

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

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:

            new DiviPresetsSetAbility(
                $this->designWriter,
                $this->designChange(),
                $this->globalUsages(),
                $loader,
                $this->presetComposer(...)
            ),

und die Helfer:

    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

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:

    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

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
    /**
     * @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

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

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

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

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

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

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:

            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

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:

## 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:

#!/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:

## 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

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).