# 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/.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/` (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/.sh`. - Commits: `git commit -m "" -m "Co-Authored-By: Claude Opus 5.5 "` (im Folgenden „Commit wie in den Global Constraints“). Nur die Dateien des Tasks stagen. ## Review Focus 1. **Preset mit Skript- oder HTML-Attributen von einem Redakteur ohne `unfiltered_html`** (über `set`/`attrs` oder `from_node` einer Admin-Seite): abgelehnt, nichts gespeichert → `PresetComposerTest::testEditorCannotCarryBuilderOnlyValuesFromANode`, `PresetsSetAbilityTest::testEditorWithoutUnfilteredHtmlIsRefusedForRawCss` (Tasks 9, 11). 2. **Inhalte im Preset** (Text, URL, Bild-ID): abgelehnt, weil Divis Preset-Mapping sie nicht als Preset-Attribut führt → `PresetComposerTest::testContentPathsAreRefused` (Task 9). 3. **Parallele Änderung** zwischen Probelauf und `confirm` (anderes Fenster im Builder): `rev_mismatch`, nichts geschrieben, kein Snapshot → `DesignChangeTest::testStaleDesignRevWritesNothing` (Task 8). 4. **Schreiben scheitert oder liest anders zurück**: Snapshot wird zurückgeschrieben, `write_failed`; scheitert auch das, `partial_write` mit `snapshot_id` → `DesignChangeTest::testFailedWriteRestoresTheSnapshot`, `::testFailedRollbackIsPartial` (Task 8). 5. **Löschen eines benutzten Presets** (Seite, Bibliothek, Theme Builder, als verschachteltes Gruppen-Preset): `in_use` mit Fundstellen → `PresetsDeleteAbilityTest::testPresetInUseIsRefused`, `::testNestedUseInAnotherPresetCounts` (Task 12). 6. **Rechte**: Autor ohne `edit_theme_options`, Administrator mit Divi-Rolle ohne Visual Builder → `forbidden` → `PresetsSetAbilityTest::testNeedsEditThemeOptionsAndTheVisualBuilder` (Task 11). 7. **Divi nicht freigegeben**: Schreiben gesperrt (`DiviLocked`) vor jedem Snapshot → `DesignSystemWriterTest::testWritesNeedAReleasedDivi` (Task 5), `DesignChangeTest::testLockedDiviWritesNoSnapshot` (Task 8). --- ## File Structure ``` src/ Uninstaller.php (geändert) Snapshot-Optionen Integrations/Divi/ DiviIntegration.php (geändert) neue Tools, Writer, Snapshots, Usages Release/DiviAdapters.php (geändert) DesignSystemWriter Release/fingerprints.json (geändert) preset-sync Library/DiviRoles.php (unverändert) Library/GlobalUsages.php (geändert) report()/rows() über UsageQuery Library/UsageQuery.php (neu) Suchstrategie: Kandidaten-SQL + Treffer Library/LibraryUsageQuery.php (neu) bisherige Bibliothekssuche Design/ PresetScope.php (neu) module|group + voller Name PresetBook.php (neu) Preset-Bestand im Speicherformat PresetDiff.php (neu) Unterschied zweier Bestände PresetComposer.php (neu) Preset-Eintrag aus set/attrs/from_node PresetUsageMatcher.php (neu) Inhalt verwendet Preset? (rein) PresetUsageQuery.php (neu) UsageQuery für ein Preset DesignSystemWriter.php (neu) Adapter: Presets schreiben, Mapping, Rechte SnapshotStore.php (neu) Snapshots je Bereich SnapshotTooLarge.php (neu) Ausnahme DesignArea.php (neu) Schnittstelle eines Bereichs PresetArea.php (neu) Bereich presets DesignChange.php (neu) Pipeline DesignPlan.php (neu) Ergebnis der Planung (neuer Bestand, diff, affected) DiviPresetGetAbility.php (neu) lit/divi-preset-get DiviPresetsSetAbility.php (neu) lit/divi-presets-set DiviPresetsDeleteAbility.php (neu) lit/divi-presets-delete DiviDesignSnapshotsAbility.php (neu) lit/divi-design-snapshots DiviDesignRestoreAbility.php (neu) lit/divi-design-restore tests/ Fixtures/divi/reference/preset-sync.txt (neu) Referenz des Mirrors Unit/Integrations/Divi/Design/…Test.php (neu) je Klasse Unit/Integrations/Divi/Library/GlobalUsagesTest.php (geändert) Suche über UsageQuery Unit/UninstallerTest.php (geändert) Integration/divi-presets-smoke.sh (neu) Ende-zu-Ende gegen echtes Divi docs/ guides/divi-design-system.md (geändert) Abschnitt „Presets anlegen und ändern“ superpowers/notes/2026-09-28-etappe-4-divi-formate.md (geändert) Stand 5.15.1 ``` --- ### Task 1: Datenformate und Signaturen gegen Divi 5.15.1 prüfen (Presets, Rechte, Caches) Reiner Recherche-Task; Ergebnis ist die nachgeführte Formate-Notiz. Kein Produktionscode. **Files:** - Modify: `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` **Interfaces:** - Consumes: — - Produces: verifizierte Fakten, auf die Tasks 2–14 bauen (Abschnitt „Nachtrag 5.15.1“). - [ ] **Step 1: Divi-Version und Preset-Funktionen feststellen** Run: ```bash cd /home/janniclas/Projekte/WordPress/wp-content/themes/Divi grep -m1 "Version:" style.css cd includes/builder-5/server grep -n "public static function \(option_name\|get_data\|prepare_data\|validate_preset_count\|save_data\|get_deleted_preset_ids\|remove_preset_from_legacy_option\)" Packages/GlobalData/GlobalPreset.php grep -n "public static function \(get_preset_attrs\|get_preset_attrs_mapping\)" Packages/Conversion/Conversion.php grep -n "public static function can_current_user_use_visual_builder" Framework/UserRole/UserRole.php grep -n "public static function sync\|sync_permission\|edit_theme_options" Packages/GlobalData/GlobalPresetController.php ``` Expected (Stand 5.15.1, beim Schreiben dieses Plans geprüft): `Version: 5.15.1`; `prepare_data( array $schema_items ): array`, `validate_preset_count( array $current_presets, array $presets_to_sync, string $action_type ): ?WP_Error`, `save_data( array $data, bool $skip_external_cache = false ): array`, `get_deleted_preset_ids( array $current_presets, array $incoming_presets, string $preset_type ): array`, `remove_preset_from_legacy_option( string $preset_id, string $module_name ): bool`; `sync_permission` = `UserRole::can_current_user_use_visual_builder() && current_user_can( 'edit_theme_options' )`. Weicht etwas ab, die Abweichung in Step 4 notieren und die betroffenen Tasks vor der Umsetzung anpassen. - [ ] **Step 2: Ablauf von `GlobalPresetController::sync` lesen** Run: `sed -n '/public static function sync/,/^\t}/p' Packages/GlobalData/GlobalPresetController.php` Festhalten (für den Mirror in Task 5): ohne Chunks `prepare_data($presets)` → `get_data()` → `validate_preset_count($current, $prepared, $actionType)` → bei `DELETE_MODULE_PRESET`/`DELETE_OPTION_GROUP_PRESET` `get_deleted_preset_ids(…, 'module'|'group')` und je Eintrag `remove_preset_from_legacy_option($id, $moduleName)` → `save_data($prepared)`. - [ ] **Step 3: Gespeichertes Format am lebenden Bestand prüfen** Run: ```bash cd /home/janniclas/Projekte/WordPress ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-seed-stage4-design.php ddev wp eval 'echo wp_json_encode(ET\Builder\Packages\GlobalData\GlobalPreset::get_data(), JSON_PRETTY_PRINT);' | head -80 ddev wp eval '$m = ET\Builder\Packages\Conversion\Conversion::get_preset_attrs_mapping("divi/button"); echo wp_json_encode(array_slice($m, 0, 5, true), JSON_PRETTY_PRINT);' ``` Expected: Speicherformat wie Formate §3 (`module`/`group` → Name → `{default, items: {id: item}}`, Einträge mit `type`, `id`, `name`, `moduleName`, `version`, `created`/`updated` in Millisekunden, `attrs`, `styleAttrs`, `renderAttrs`). Das Mapping ist ein Array Attributname → Einträge mit `attrName`, `preset` (Liste aus `style`/`html`/`script`/`content`). Abweichungen notieren. - [ ] **Step 4: Notiz nachführen** In `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` direkt nach der Einleitung einfügen: ```markdown ## Nachtrag Divi 5.15.1 (Etappe 4a, 2026-10-16) Geprüft gegen Divi 5.15.1 (installiert, freigegeben): - `GlobalPreset::save_data()` hat den zweiten Parameter `$skip_external_cache = false`; das Plugin übergibt ihn nicht. - `GlobalPresetController::sync_permission()` verlangt jetzt **zusätzlich `edit_theme_options`** (5.2.1: nur Visual-Builder-Recht). Die Rechte in Spec §10 entsprechen damit Divi. - Der Sync nimmt Chunks an (`requestId`, `chunkIndex`, `totalChunks`, Transient `et_global_preset_chunks__`); 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`). - ``` Die letzte Zeile mit dem Ergebnis von Step 1–3 füllen (bei keiner Abweichung „Keine weiteren Abweichungen zu §3.“). - [ ] **Step 5: Commit** ```bash git add docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md git commit -m "docs: Divi-Formate der Presets gegen 5.15.1 geprüft" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### 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; public function toRest(): array; public static function newId(): string }` - [ ] **Step 1: Failing tests schreiben** `tests/Unit/Integrations/Divi/Design/PresetScopeTest.php`: ```php '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}> */ 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 $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 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 ) or an option group preset * ("group" + divi/). 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 $item An input entry with exactly one of "module" or "group". * @throws InvalidArgumentException ": " 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 : {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>}>, * group: array>}>} $data */ private function __construct(private array $data) { } /** @param array|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 */ 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> 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|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 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 $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 */ 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>}>, * group: list>}>} */ public function toRest(): array { $rest = [PresetScope::MODULE => [], PresetScope::GROUP => []]; foreach ($this->data as $kind => $records) { foreach ($records as $record) { $rest[$kind][] = ['default' => $record['default'], 'items' => array_values($record['items'])]; } } return $rest; } /** A new preset ID like the Visual Builder's generateNanoUuid(): 10 characters of [0-9a-z] (formats §3). */ public static function newId(): string { $id = ''; for ($i = 0; $i < 10; ++$i) { $id .= self::ALPHABET[random_int(0, 35)]; } return $id; } private static function plain(mixed $value): mixed { if (is_object($value)) { $value = get_object_vars($value); } return is_array($value) ? array_map([self::class, 'plain'], $value) : $value; } } ``` - [ ] **Step 5: Tests laufen lassen – sie bestehen** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetScopeTest.php tests/Unit/Integrations/Divi/Design/PresetBookTest.php` Expected: PASS (alle Tests grün). - [ ] **Step 6: Gesamtlauf und Lint** Run: `composer test && composer lint` Expected: OK, keine phpcs-Fehler. - [ ] **Step 7: Commit** ```bash git add src/Integrations/Divi/Design/PresetScope.php src/Integrations/Divi/Design/PresetBook.php tests/Unit/Integrations/Divi/Design/PresetScopeTest.php tests/Unit/Integrations/Divi/Design/PresetBookTest.php git commit -m "feat: PresetScope und PresetBook – Preset-Bestand im Divi-Speicherformat" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### 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}> }` – `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 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 }> */ 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> $was * @param array> $is * @return list> */ 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>} $was * @param array{default: string, items: array>} $is * @return list> */ 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 $before * @param array $after * @return list */ 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|null $item * @return array{kind: string, scope: string, id: string, name: string, action: string} */ private static function row(string $kind, string $scope, string $id, ?array $item, string $action): array { return [ 'kind' => $kind, 'scope' => $scope, 'id' => $id, 'name' => is_string($item['name'] ?? null) ? $item['name'] : $id, 'action' => $action, ]; } } ``` Hinweis zur losen Gleichheit in `changedFields()`: Divi speichert Zahlen mal als String, mal als Zahl (`order: "1"`); `!=` vergleicht Arrays rekursiv ohne Typ- und Schlüsselreihenfolge-Strenge – genau das gewünschte „inhaltlich gleich“. Meldet phpcs eine andere Regel als `Universal.Operators.StrictComparisons`, den Regelnamen im `phpcs:ignore` daran anpassen. Die erwartete Reihenfolge im Test folgt aus der Sortierung: je Bereich zuerst alle Einträge nach ID (`aaaaaaaaaa` changed, `bbbbbbbbbb` removed, `eeeeeeeeee` added), dann der Standard. - [ ] **Step 4: Test laufen lassen – er besteht** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetDiffTest.php` Expected: PASS. - [ ] **Step 5: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` ```bash git add src/Integrations/Divi/Design/PresetDiff.php tests/Unit/Integrations/Divi/Design/PresetDiffTest.php git commit -m "feat: PresetDiff – Unterschied zweier Preset-Bestände für den Probelauf" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### 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; 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 */ private array $options = []; protected function setUp(): void { parent::setUp(); $this->options = []; Functions\when('get_option')->alias(fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default); Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool { self::assertFalse($autoload, 'snapshots are never autoloaded'); $this->options[$name] = $value; return true; }); Functions\when('get_current_user_id')->justReturn(7); } public function testSavesListsAndReturnsTheNewestFirst(): void { $store = new SnapshotStore(); $first = $store->save('presets', ['module' => ['a' => 1]], 'lit/divi-presets-set'); $second = $store->save('presets', ['module' => ['a' => 2]], 'lit/divi-presets-set'); $store->attachLog('presets', $second, 42); self::assertSame('lit_mcp_snapshots_presets', SnapshotStore::option('presets')); self::assertMatchesRegularExpression('/^presets-\d{8}T\d{6}Z-[0-9a-f]{6}$/', $first); $list = $store->list('presets'); self::assertSame([$second, $first], array_column($list, 'id')); self::assertSame(42, $list[0]['log_id']); self::assertSame(7, $list[0]['user_id']); self::assertArrayNotHasKey('data', $list[0]); self::assertSame(['module' => ['a' => 1]], $store->get('presets', $first)['data']); self::assertNull($store->get('presets', 'presets-unknown')); self::assertSame([], $store->list('colors')); } public function testKeepsAtMostTwentyPerArea(): void { $store = new SnapshotStore(); $ids = []; for ($i = 0; $i < 22; ++$i) { $ids[] = $store->save('presets', ['n' => $i], 'lit/divi-presets-set'); } $list = $store->list('presets'); self::assertCount(SnapshotStore::LIMIT, $list); self::assertSame($ids[21], $list[0]['id']); self::assertNull($store->get('presets', $ids[0])); self::assertNull($store->get('presets', $ids[1])); } public function testRefusesSnapshotsAboveTheLimit(): void { $this->expectException(SnapshotTooLarge::class); (new SnapshotStore())->save('presets', ['x' => str_repeat('a', SnapshotStore::MAX_BYTES)], 'lit/divi-presets-set'); } public function testUnknownAreaIsAProgrammingError(): void { $this->expectException(InvalidArgumentException::class); SnapshotStore::option('pages'); } public function testAFailedOptionWriteThrows(): void { Functions\when('update_option')->justReturn(false); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('snapshot presets could not be stored'); (new SnapshotStore())->save('presets', ['a' => 1], 'lit/divi-presets-set'); } public function testCorruptOptionReadsAsEmpty(): void { $this->options['lit_mcp_snapshots_presets'] = 'kaputt'; self::assertSame([], (new SnapshotStore())->list('presets')); } } ``` `update_option()` liefert `false`, wenn der Wert unverändert ist. Ein neuer Snapshot ändert die Liste immer (neue ID am Anfang), daher ist `false` hier ein echter Fehler. In `tests/Unit/UninstallerTest.php` den Test, der die gelöschten Optionen prüft, um die vier Snapshot-Optionen ergänzen. Dafür in der Liste der erwarteten Optionen (Suche nach `'lit_mcp_divi_check_lock'`) anhängen: ```php 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', 'lit_mcp_snapshots_theme_options', ``` - [ ] **Step 2: Tests laufen lassen – sie scheitern** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php tests/Unit/UninstallerTest.php` Expected: FAIL (`SnapshotStore` fehlt; Uninstaller löscht die neuen Optionen nicht). - [ ] **Step 3: Implementieren** `src/Integrations/Divi/Design/SnapshotTooLarge.php`: ```php , 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 $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 */ 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}|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}> */ private function entries(string $area): array { $stored = get_option(self::option($area), []); if (! is_array($stored)) { return []; } $entries = []; foreach ($stored as $entry) { if (is_array($entry) && is_string($entry['id'] ?? null) && is_array($entry['data'] ?? null)) { $entries[] = [ 'id' => $entry['id'], 'created' => (string) ($entry['created'] ?? ''), 'user_id' => (int) ($entry['user_id'] ?? 0), 'ability' => (string) ($entry['ability'] ?? ''), 'log_id' => isset($entry['log_id']) ? (int) $entry['log_id'] : null, 'size' => (int) ($entry['size'] ?? 0), 'data' => $entry['data'], ]; } } return $entries; } } ``` In `src/Uninstaller.php` die Konstante `OPTIONS` erweitern: ```php private const OPTIONS = [ 'lit_mcp_allowed_roles', 'lit_mcp_exposure', 'lit_mcp_limits', 'lit_mcp_db_version', 'lit_mcp_divi_releases', 'lit_mcp_divi_check_report', 'lit_mcp_divi_check_lock', 'lit_mcp_snapshots_colors', 'lit_mcp_snapshots_variables', 'lit_mcp_snapshots_presets', 'lit_mcp_snapshots_theme_options', ]; ``` Den Docblock von `Uninstaller` um „Snapshots des Designsystems (Etappe 4)“ in der Aufzählung der entfernten Daten ergänzen. - [ ] **Step 4: Tests laufen lassen – sie bestehen** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php tests/Unit/UninstallerTest.php` Expected: PASS. - [ ] **Step 5: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` ```bash git add src/Integrations/Divi/Design/SnapshotStore.php src/Integrations/Divi/Design/SnapshotTooLarge.php src/Uninstaller.php tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php tests/Unit/UninstallerTest.php git commit -m "feat: SnapshotStore – Snapshots des Designsystems je Bereich" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### 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 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 The stored inventory (GlobalPreset::get_data(), formats §3). */ public function presets(): array { $data = GlobalPreset::get_data(); return is_array($data) ? $data : []; } /** * @param array $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 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 $attrs * @return array{all: array, style: array, render: array} */ public function presetAttrs(string $moduleName, array $attrs): array { $map = Conversion::get_preset_attrs_mapping($moduleName); return [ 'all' => (array) Conversion::get_preset_attrs($attrs, self::PRESET_TYPES, $map), 'style' => (array) Conversion::get_preset_attrs($attrs, ['style'], $map), 'render' => (array) Conversion::get_preset_attrs($attrs, ['html', 'script'], $map), ]; } /** Divi's role right "Visual Builder verwenden"; fail closed without Divi. */ public function canUseVisualBuilder(): bool { return class_exists(UserRole::class) && UserRole::can_current_user_use_visual_builder(); } } ``` In `src/Integrations/Divi/Release/DiviAdapters.php` `DesignSystemWriter::class` direkt nach `DesignSystemReader::class` in `CLASSES` aufnehmen und importieren (`use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;`). - [ ] **Step 4: Unit-Tests laufen lassen** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php tests/Unit/Integrations/Divi/Release` Expected: PASS bis auf Tests, die den Fingerabdruck jedes Mirrors in `fingerprints.json` erwarten (z. B. „Mirror ohne Soll-Fingerabdruck“) – die werden in Step 5 grün. - [ ] **Step 5: Fingerabdruck und Referenz erzeugen** Run: ```bash cd /home/janniclas/Projekte/WordPress ddev wp --user=admin lit-mcp divi-check --update-reference cd wp-content/plugins/WP-LIT-MCP && git diff --stat src/Integrations/Divi/Release/fingerprints.json && ls tests/Fixtures/divi/reference/preset-sync.txt ``` Expected: `fingerprints.json` bekommt genau den neuen Schlüssel `preset-sync` (die übrigen Werte bleiben); `tests/Fixtures/divi/reference/preset-sync.txt` existiert und enthält den normalisierten Quelltext von `GlobalPresetController::sync`. Ändern sich andere Schlüssel, nicht committen, sondern prüfen (installiertes Divi ≠ freigegebene Version). - [ ] **Step 6: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` Expected: OK; `DiviBoundaryTest` grün (alle Divi-Namen des Writers stehen im Manifest). ```bash git add src/Integrations/Divi/Design/DesignSystemWriter.php src/Integrations/Divi/Release/DiviAdapters.php src/Integrations/Divi/Release/fingerprints.json tests/Fixtures/divi/reference/preset-sync.txt tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php git commit -m "feat: DesignSystemWriter – Presets über Divis Sync-Ablauf schreiben (Mirror preset-sync)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### 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`; `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 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('')); self::assertFalse($query->matches('')); } } ``` In `tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php` ergänzen (Aufbau der vorhandenen `$wpdb`-Mocks des Tests übernehmen; der Test erwartet, dass die Kandidatenabfrage die Bedingung der Suche enthält): ```php public function testRowsForUsesTheConditionAndMatcherOfTheQuery(): void { $db = Mockery::mock(\wpdb::class)->makePartial(); $queries = []; $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array { $queries[] = $sql; return str_contains($sql, 'post_content FROM') ? [['ID' => '5', 'post_content' => 'treffer'], ['ID' => '6', 'post_content' => 'nichts']] : [['ID' => '5', 'post_status' => 'draft', 'post_type' => 'page'], ['ID' => '6', 'post_status' => 'draft', 'post_type' => 'page']]; }); $query = new class implements \LIT\MCP\Integrations\Divi\Library\UsageQuery { public function candidateCondition(\wpdb $db): string { return '(MARKER)'; } public function matches(string $content): bool { return $content === 'treffer'; } }; $rows = (new GlobalUsages($db))->rowsFor($query); self::assertSame([['id' => 5, 'status' => 'draft', 'type' => 'page']], $rows); self::assertStringContainsString('WHERE (MARKER) AND post_type', $queries[0]); } ``` Der bestehende `testFindsEveryFormatDiviRendersFromCandidates` bleibt unverändert und muss grün bleiben: `LibraryUsageQuery` erzeugt mit dem echten `prepare()` des `wpdb`-Stubs wörtlich dieselbe Kandidaten-SQL wie bisher. - [ ] **Step 2: Tests laufen lassen – sie scheitern** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php` Expected: FAIL (`LibraryUsageQuery`/`UsageQuery` fehlen, `rowsFor()` unbekannt). - [ ] **Step 3: Implementieren** `src/Integrations/Divi/Library/UsageQuery.php`: ```php 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 prepare( '(LOWER(post_content) LIKE %s OR LOWER(post_content) LIKE %s OR post_content REGEXP %s)', '%' . $db->esc_like('globalmodule') . '%', '%' . $db->esc_like('global_module') . '%', str_repeat(chr(92), 2) . 'u00[4-7][0-9a-fA-F]' ); } public function matches(string $content): bool { return UsageMatcher::shows($content, $this->libraryId); } } ``` In `src/Integrations/Divi/Library/GlobalUsages.php`: 1. `report()`, `rows()` und die neuen Methoden – `report()` ruft weiter `rows()` auf, damit bestehende Tests, die `rows()` überschreiben, wirksam bleiben: ```php /** * The usages of $libraryId with the caller's rights; $withBlocked adds the P8a check (edit_post * on every live post) – the dry run and the confirm of an edit need it, a listing does not. */ public function report(int $libraryId, bool $withBlocked = true): UsageReport { return $this->reportOfRows($this->rows($libraryId), $withBlocked); } /** report() for any reference – a library entry, a preset (Etappe 4) – described by $query. */ public function reportFor(UsageQuery $query, bool $withBlocked = true): UsageReport { return $this->reportOfRows($this->rowsFor($query), $withBlocked); } /** @return list */ 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 */ public function rowsFor(UsageQuery $query): array { $candidates = $this->candidates($query); $result = []; foreach (array_chunk(array_slice($candidates, 0, self::PARSE_CAP), self::CONTENT_CHUNK) as $chunk) { $contents = $this->contents(array_map(static fn (array $row): int => $row['id'], $chunk)); foreach ($chunk as $row) { if (! isset($contents[$row['id']]) || $query->matches($contents[$row['id']])) { $result[] = $row; } } } return array_merge($result, array_slice($candidates, self::PARSE_CAP)); } ``` 2. Der bisherige Rumpf von `report()` ab der Zeile `$live = count(array_filter($rows, …));` bis zum `return new UsageReport(…);` wird **wörtlich** in die neue private Methode verschoben: ```php /** @param list $rows */ private function reportOfRows(array $rows, bool $withBlocked): UsageReport { $live = count(array_filter($rows, static fn (array $row): bool => in_array($row['status'], self::LIVE, true))); // … ab hier die bisherigen Zeilen von report() bis einschließlich "return new UsageReport(…);" … } ``` 3. `candidates()` nimmt die Suche: ```php /** @return list */ private function candidates(UsageQuery $query): array { $db = $this->db(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery $rows = $db->get_results( 'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE ' . $query->candidateCondition($db) . " AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC', 'ARRAY_A' ); // phpcs:enable // … unveränderte Umwandlung in $result … } ``` 4. Klassen-Docblock: erster Satz „Posts (pages, Theme Builder layouts, other library entries) that reference something a UsageQuery describes – a library entry $id (LibraryUsageQuery) or, since Etappe 4, a preset: …“; der Rest bleibt. Alle übrigen Erwartungen von `GlobalUsagesTest` (Kandidaten-SQL, Rechte, Grenzen, Theme Builder) bleiben unverändert. - [ ] **Step 4: Tests laufen lassen – sie bestehen** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Library` Expected: PASS, alle Bibliotheks-Tests grün. - [ ] **Step 5: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` ```bash git add src/Integrations/Divi/Library/UsageQuery.php src/Integrations/Divi/Library/LibraryUsageQuery.php src/Integrations/Divi/Library/GlobalUsages.php tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php tests/Unit/Integrations/Divi/Library/LibraryUsageQueryTest.php git commit -m "refactor: GlobalUsages sucht über UsageQuery – Bibliothekssuche als LibraryUsageQuery" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### 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`, 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 ' . ''; 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( '', new PresetScope('module', 'divi/button'), 'aaaaaaaaaa', false )); } } ``` `tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php`: ```php allows('esc_like')->andReturnUsing(static fn (string $text): string => $text); $db->expects('prepare') ->with( '(post_content LIKE %s OR post_content LIKE %s OR post_content REGEXP %s)', '%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('')); self::assertFalse($query->matches('')); } 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('')); } 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 .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 $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 */ private array $targets; /** * @param list $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 `` im Inhalt (ohne Leerzeichen-JSON); die Kandidatenbedingung `wp:divi/button ` (mit Leerzeichen) trifft beide Schreibweisen, weil auf den Namen immer ein Leerzeichen folgt. - [ ] **Step 4: Tests laufen lassen – sie bestehen** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php` Expected: PASS. - [ ] **Step 5: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` ```bash git add src/Integrations/Divi/Design/PresetUsageMatcher.php src/Integrations/Divi/Design/PresetUsageQuery.php tests/Unit/Integrations/Divi/Design/PresetUsageMatcherTest.php tests/Unit/Integrations/Divi/Design/PresetUsageQueryTest.php git commit -m "feat: Fundstellen eines Presets (PresetUsageMatcher, PresetUsageQuery)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### 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( 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 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 */ public function raw(): array; /** @param array $raw */ public function rev(array $raw): string; /** * @param array $raw * @return array 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 $expected * @param array $actual */ public function verify(array $expected, array $actual): bool; } ``` ```php $raw * @param list> $diff * @param list $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 > 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 */ public static function outputSchema(): array { return ToolResult::schema([ 'diff' => ['type' => 'array', 'items' => ['type' => 'object']], 'design_rev' => ['type' => 'string'], ], ['diff', 'design_rev']); } /** * @param array $input The tool input (confirm, design_rev). * @param callable(array): (DesignPlan|WP_Error) $plan * @return array|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 $current * @return array * @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 $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 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 writer->presets())->data(); } public function rev(array $raw): string { return PresetBook::fromDivi($raw)->rev(); } public function write(array $raw, bool $mayRemove): array { return PresetBook::fromDivi($this->writer->savePresets(PresetBook::fromDivi($raw)->toRest(), $mayRemove)) ->data(); } public function verify(array $expected, array $actual): bool { $structural = array_filter( PresetDiff::between(PresetBook::fromDivi($expected), PresetBook::fromDivi($actual)), static fn (array $row): bool => $row['action'] !== 'changed' ); return $structural === []; } } ``` - [ ] **Step 8: Tests laufen lassen – sie bestehen** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design` Expected: PASS. - [ ] **Step 9: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` ```bash git add src/Integrations/Divi/Design/DesignArea.php src/Integrations/Divi/Design/DesignPlan.php src/Integrations/Divi/Design/DesignChange.php src/Integrations/Divi/Design/PresetArea.php tests/Unit/Integrations/Divi/Design/DesignChangeTest.php tests/Unit/Integrations/Divi/Design/PresetAreaTest.php git commit -m "feat: Änderungs-Pipeline des Designsystems (Probelauf, design_rev, Snapshot, Rollback) und Bereich presets" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### 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` 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, errors: array, invalid_paths: list}`; `$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/` aus der Eingabe; fehlt alles → Fehler `.source_module`. - Gruppe (`groupId`) eines neuen Gruppen-Presets: der eine Pfad aus `groupsOf()` mit Gruppenname `$scope->name`; mehrere → Eingabe `group_id` nötig; keiner → Fehler. - Ausgangswerte: `existing.attrs` bzw. `[]`; mit `$source`: zuerst `StoredNodeGuard::problems($source)` (Fehler `.from_node`), dann die Preset-Attribute des Knotens (`presetAttrs(, $source->attrs())['all']`, bei Gruppen nur unterhalb `groupId`) über die Ausgangswerte gelegt. - `set`/`attrs` laufen durch `Composer::update()` an `Block::create(, )`. - Danach muss jeder Pfad Preset-Attribut sein (`presetAttrs(...)['all']` enthält ihn) – sonst `.attrs.`: „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): `{ "": "" | null }`, Gruppe als Kurzname (`font`), voller Name (`divi/font`) oder Pfad (`button.decoration.font`); Auflösung über `groupsOf()`, Preset über `PresetBook::find()` im Bereich `group`. - Eintrag: `type`, `id` (bestehend oder `PresetBook::newId()`), `name` (`rename` vor `name` vor bestehend), bei Gruppen `groupName`/`groupId`, `moduleName`, `version`, `created` (bestehend oder jetzt), `updated` (jetzt), `attrs`, `styleAttrs`/`renderAttrs` (nur wenn nicht leer), `groupPresets` (nur wenn nicht leer), `priority`/`order` vom bestehenden Eintrag. - [ ] **Step 1: `groupsOf()` im Writer – Test ergänzen** In `DesignSystemWriterTest::testIsARegisteredDesignAdapterWithTheSyncMirror` die erwartete Liste um zwei Einträge ergänzen (nach `'Conversion::get_preset_attrs()',`): ```php 'GlobalPreset::get_group_preset_default_attr()', 'ModuleRegistration::get_module_settings()', ``` und einen Test anhängen: ```php public function testWithoutDiviAModuleHasNoGroups(): void { self::assertSame([], (new DesignSystemWriter())->groupsOf('divi/button')); } ``` - [ ] **Step 2: `groupsOf()` implementieren** In `DesignSystemWriter`: Import `use ET\Builder\Packages\ModuleLibrary\ModuleRegistration;`, im Manifest nach `Conversion::get_preset_attrs` ergänzen: ```php new Call(GlobalPreset::class . '::get_group_preset_default_attr', 1), new Call(ModuleRegistration::class . '::get_module_settings', 1), ``` und die Methode: ```php /** * The option groups of $moduleName that take group presets: attribute path (groupId) => group name, e.g. * "module.decoration.spacing" => "divi/spacing" (formats §3). [] without Divi or for an unknown module. * * @return array */ 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 $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 `.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 ): array{all: array, style: array, * render: array} $presetAttrs DesignSystemWriter::presetAttrs() * @param Closure(string): array $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|null $existing The preset to change, null for a new one. * @param array $input One item of lit/divi-presets-set. * @return array{item: array, errors: array, invalid_paths: list} */ 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|null $existing * @param array $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|null $existing * @param array $input * @param array $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|null $existing * @param array $input * @param array $errors * @return array, 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 $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|null $existing * @param array $input * @param array $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|null $existing * @param array $attrs * @param array{all: array, style: array, render: array} $split * @param array $nested * @return array */ 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 $attrs * @return array */ private static function below(array $attrs, string $groupId): array { $value = AttrMap::get($attrs, $groupId); return $value === null ? [] : AttrMap::set([], $groupId, $value); } } ``` `AttrMap::get()` liest einen Punkt-Pfad (`null`, wenn er fehlt), `AttrMap::set()` legt fehlende Zwischenstufen an. - [ ] **Step 6: Test laufen lassen – er besteht** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetComposerTest.php` Expected: PASS. - [ ] **Step 7: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` ```bash git add src/Integrations/Divi/Design/PresetComposer.php src/Integrations/Divi/Design/DesignSystemWriter.php tests/Unit/Integrations/Divi/Design/PresetComposerTest.php tests/Unit/Integrations/Divi/Design/DesignSystemWriterTest.php git commit -m "feat: PresetComposer – Preset aus Kurz-/Vollform, Modul und Gruppen-Presets" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### 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 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 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 $item * @return list}> */ private function nested(PresetBook $book, array $item): array { $rows = []; foreach (is_array($item['groupPresets'] ?? null) ? $item['groupPresets'] : [] as $groupId => $nested) { $groupName = is_array($nested) && is_string($nested['groupName'] ?? null) ? $nested['groupName'] : ''; $presets = []; foreach (is_array($nested) ? (array) ($nested['presetId'] ?? []) : [] as $presetId) { $found = $book->items(new PresetScope(PresetScope::GROUP, $groupName))[(string) $presetId] ?? null; $presets[] = ['id' => (string) $presetId, 'name' => (string) ($found['name'] ?? $presetId)]; } $rows[] = ['group_id' => (string) $groupId, 'group_name' => $groupName, 'presets' => $presets]; } return $rows; } } ``` `ShortformMap::values()` erwartet den kurzen Modultyp (`button`), wie `Block::shortName()` ihn aus `divi/button` bildet; für einen Typ ohne Kurzform liefert es `[]` (`fields()` gibt dann `[]`). In `DiviIntegration`: - Eigenschaft `private DesignSystemWriter $designWriter;`, im Konstruktor `$this->designWriter = new DesignSystemWriter();`. - In `abilities()` am Ende `new DiviPresetGetAbility($this->designWriter, $this->globalUsages()),` ergänzen; Import. In `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` die Namensliste um `'lit/divi-preset-get',` ergänzen (hinter `'lit/divi-dynamic-sources',`). - [ ] **Step 4: Tests laufen lassen – sie bestehen** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php tests/Unit/Guides/ToolNamingTest.php` Expected: PASS. - [ ] **Step 5: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` ```bash git add src/Integrations/Divi/Design/DiviPresetGetAbility.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/DiviPresetGetAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php git commit -m "feat: lit/divi-preset-get – Preset mit Einstellungen und Fundstellen lesen" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 11: `lit/divi-presets-set` – Presets anlegen und ändern **Files:** - Create: `src/Integrations/Divi/Design/DesignRights.php` - Create: `src/Integrations/Divi/Design/DiviPresetsSetAbility.php` - Modify: `src/Integrations/Divi/Design/PresetDiff.php` (neue Methode `summary()`) - Modify: `tests/Unit/Integrations/Divi/Design/PresetDiffTest.php` - Modify: `src/Integrations/Divi/DiviIntegration.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` - Test: `tests/Unit/Integrations/Divi/Design/DesignRightsTest.php` - Test: `tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php` **Interfaces:** - Consumes: `DesignChange::run()`, `DesignChange::inputProperties()`, `DesignChange::outputSchema()`, `DesignPlan`, `PresetArea`, `PresetBook`, `PresetScope`, `PresetDiff`, `PresetComposer`, `PresetUsageQuery`, `GlobalUsages::reportFor()`, `DiviPageLoader::load(mixed, bool)` (→ `DiviPage|WP_Error`, `$page->index->find(string): ?Block`). - Produces: - `final class DesignRights { public static function presets(DesignSystemWriter $writer): ?WP_Error }` – `edit_theme_options` + Visual Builder. - `PresetDiff::summary(array $diff): string` – z. B. „Presets: 1 angelegt, 1 geändert, Standard 1× gesetzt.“ - `final class DiviPresetsSetAbility implements Ability { public const MAX_ITEMS = 20; public function __construct(DesignSystemWriter $writer, DesignChange $change, GlobalUsages $usages, DiviPageLoader $loader, Closure $composer) }` – `$composer`: `Closure(): PresetComposer`. Eingabe (Spec §6.3): `items` (1–20) mit je `module`|`group`, `preset` (zu ändern: Name oder ID; fehlt → neu), `name` (neu), `rename`, `set`, `attrs`, `from_node` (`{post_id, id}`), `source_module`, `group_id`, `group_presets`, `make_default` (bool); dazu `confirm`, `design_rev`. - [ ] **Step 1: Failing tests für `PresetDiff::summary()` und `DesignRights` schreiben** In `PresetDiffTest` anhängen: ```php public function testSummaryCountsTheActions(): void { self::assertSame( 'Presets: 1 angelegt, 1 geändert, 1 gelöscht, Standard 1× gesetzt, 1× entfernt.', PresetDiff::summary([ ['action' => 'added'], ['action' => 'changed'], ['action' => 'removed'], ['action' => 'default_set'], ['action' => 'default_cleared'], ]) ); self::assertSame('Presets: 2 angelegt.', PresetDiff::summary([['action' => 'added'], ['action' => 'added']])); self::assertSame('Presets: keine Änderung.', PresetDiff::summary([])); } ``` `tests/Unit/Integrations/Divi/Design/DesignRightsTest.php`: ```php allows('canUseVisualBuilder')->andReturn(true, false); Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options'); self::assertNull(DesignRights::presets($writer)); $refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden'); self::assertStringContainsString('Visual Builder', $refusal['message']); Functions\when('current_user_can')->justReturn(false); $refusal = self::assertToolError(DesignRights::presets($writer), 'forbidden'); self::assertStringContainsString('edit_theme_options', $refusal['message']); } } ``` - [ ] **Step 2: Tests laufen lassen – sie scheitern** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/PresetDiffTest.php tests/Unit/Integrations/Divi/Design/DesignRightsTest.php` Expected: FAIL. - [ ] **Step 3: `summary()` und `DesignRights` implementieren** In `PresetDiff`: ```php /** One German sentence for the dry run and the change log (spec §4 step 6). */ public static function summary(array $diff): string { $count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff)); $parts = array_filter([ isset($count['added']) ? $count['added'] . ' angelegt' : null, isset($count['changed']) ? $count['changed'] . ' geändert' : null, isset($count['removed']) ? $count['removed'] . ' gelöscht' : null, ]); $defaults = array_filter([ isset($count['default_set']) ? $count['default_set'] . '× gesetzt' : null, isset($count['default_cleared']) ? $count['default_cleared'] . '× entfernt' : null, ]); if ($defaults !== []) { $parts[] = 'Standard ' . implode(', ', $defaults); } return 'Presets: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.'; } ``` (`@param list> $diff` im Docblock ergänzen.) `src/Integrations/Divi/Design/DesignRights.php`: ```php 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 */ 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 = '' . '' . ''; $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 '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 $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 $item * @param array $errors * @return array{PresetScope, array|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 $item * @param array $errors */ private function source(PresetScope $scope, array $item, string $field, array &$errors): Block|false|null { $from = $item['from_node'] ?? null; if ($from === null) { return null; } if (! is_array($from) || ! is_string($from['id'] ?? null)) { $errors[$field . '.from_node'] = 'post_id und id (Pfad-ID aus lit-divi-outline) erwartet'; return false; } $page = $this->loader->load($from['post_id'] ?? null, false); if ($page instanceof WP_Error) { $errors[$field . '.from_node.post_id'] = Errors::clientMessage($page); return false; } $block = $page->index->find($from['id']); if ($block === null) { $errors[$field . '.from_node.id'] = sprintf('unbekannte Pfad-ID %s (lit-divi-outline)', $from['id']); return false; } if ($scope->kind === PresetScope::MODULE && $block->name() !== $scope->name) { $errors[$field . '.from_node.id'] = sprintf('%s ist %s, nicht %s', $from['id'], $block->name(), $scope->name); return false; } return $block; } } ``` `false` steht für „Fehler schon in `$errors` notiert“, `null` für „kein `from_node`“ (`Block|false|null` ist ab PHP 8.0 gültig). In `DiviIntegration::abilities()` ergänzen: ```php new DiviPresetsSetAbility( $this->designWriter, $this->designChange(), $this->globalUsages(), $loader, $this->presetComposer(...) ), ``` und die Helfer: ```php private function designChange(): DesignChange { return new DesignChange(new SnapshotStore(), $this->log); } private function presetComposer(): PresetComposer { $composition = new DiviComposition($this->modules, $this->design); return new PresetComposer( $composition->composer(), $composition->storedGuard(), $this->designWriter->presetAttrs(...), $this->designWriter->groupsOf(...), $this->modules->builderVersion(), static fn (): int => (int) floor(microtime(true) * 1000) ); } ``` Namensliste in `DiviIntegrationTest` um `'lit/divi-presets-set',` ergänzen. - [ ] **Step 7: Tests laufen lassen – sie bestehen** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi/Design tests/Unit/Integrations/Divi/DiviIntegrationTest.php tests/Unit/Guides/ToolNamingTest.php` Expected: PASS. - [ ] **Step 8: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` ```bash git add src/Integrations/Divi/Design/DesignRights.php src/Integrations/Divi/Design/DiviPresetsSetAbility.php src/Integrations/Divi/Design/PresetDiff.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/DesignRightsTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsSetAbilityTest.php tests/Unit/Integrations/Divi/Design/PresetDiffTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php git commit -m "feat: lit/divi-presets-set – Presets anlegen und ändern mit Probelauf und Snapshot" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 12: `lit/divi-presets-delete` – unbenutzte Presets löschen **Files:** - Create: `src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php` - Modify: `src/Abilities/Errors.php` (`inUse()` mit eigenem Hinweis) - Modify: `tests/Unit/Abilities/ErrorsTest.php` - Modify: `src/Integrations/Divi/DiviIntegration.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` - Test: `tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php` **Interfaces:** - Consumes: wie Task 11, dazu `PresetBook::without()`, `PresetBook::nestedUsers()`, `UsageReport::exists()/listing()`. - Produces: - `Errors::inUse(string $message, array $usages, ?string $hint = null): WP_Error` – ohne `$hint` unverändert („… nur mit force: true …“, für `lit/media-delete`). - `final class DiviPresetsDeleteAbility implements Ability { public const MAX_ITEMS = 20; public function __construct(DesignSystemWriter $writer, DesignChange $change, GlobalUsages $usages) }`. - [ ] **Step 1: Failing tests schreiben** In `tests/Unit/Abilities/ErrorsTest.php` anhängen: ```php public function testInUseTakesItsOwnHint(): void { $error = Errors::inUse('Wird verwendet.', [['post_id' => 3]], 'Erst die Verweise entfernen.'); $payload = self::assertToolError($error, 'in_use'); self::assertSame('Erst die Verweise entfernen.', $payload['hint']); self::assertStringContainsString('force: true', Errors::inUse('x', [])->get_error_data()['hint']); } ``` (`ErrorsTest` erbt von `LIT\MCP\Tests\Unit\TestCase` und hat damit `assertToolError()`.) `tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php`: ```php justReturn(true); if ($writer === null) { $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('presets')->andReturn(PresetBookTest::stored()); $writer->allows('canUseVisualBuilder')->andReturn(true); } $usages = Mockery::mock(GlobalUsages::class); $usages->allows('reportFor')->andReturn($report); return new DiviPresetsDeleteAbility( $writer, new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)), $usages ); } private static function unused(): UsageReport { return new UsageReport(0, [], 0, 0, [], 0, false); } public function testUnusedPresetIsDeletedAfterConfirm(): void { $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('presets')->andReturn(PresetBookTest::stored()); $writer->allows('canUseVisualBuilder')->andReturn(true); $writer->expects('savePresets') ->with(Mockery::on(static function (array $rest): bool { $ids = array_column($rest['module'][0]['items'], 'id'); return $ids === ['aaaaaaaaaa', 'cccccccccc']; }), true) ->andReturnUsing(static function (): array { $stored = PresetBookTest::stored(); unset($stored['module']['divi/button']['items']['bbbbbbbbbb']); return $stored; }); $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->expects('save')->andReturn('presets-9'); $snapshots->expects('attachLog'); $ability = $this->ability(self::unused(), $writer, $snapshots); $input = ['items' => [['module' => 'button', 'preset' => 'bbbbbbbbbb']]]; $dry = self::assertToolError($ability->execute($input), 'confirm_required'); self::assertSame('removed', $dry['diff'][0]['action']); $result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]); self::assertSame('presets-9', $result['snapshot_id']); } public function testPresetInUseIsRefused(): void { $used = new UsageReport(2, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']], 1, 1, [], 0, false); $error = self::assertToolError( $this->ability($used)->execute(['items' => [['module' => 'button', 'preset' => 'CTA primär']]]), 'in_use' ); self::assertSame(3, $error['usages'][0]['post_id']); self::assertStringContainsString('lit/divi-design-detach', $error['hint']); } public function testNestedUseInAnotherPresetCounts(): void { $error = self::assertToolError( $this->ability(self::unused())->execute(['items' => [['group' => 'font-body', 'preset' => 'Lesetext']]]), 'in_use' ); self::assertSame('preset', $error['usages'][0]['type']); self::assertSame('dddddddddd', $error['usages'][0]['preset_id']); } public function testDeletingTheDefaultClearsIt(): void { $dry = self::assertToolError( $this->ability(self::unused())->execute(['items' => [['module' => 'text', 'preset' => 'Fließtext']]]), 'confirm_required' ); self::assertSame(['removed', 'default_cleared'], array_column($dry['diff'], 'action')); } public function testUnknownPreset(): void { self::assertToolError( $this->ability(self::unused())->execute(['items' => [['module' => 'button', 'preset' => 'Fehlt']]]), 'validation_failed' ); } } ``` - [ ] **Step 2: Tests laufen lassen – sie scheitern** Run: `php vendor/bin/phpunit tests/Unit/Abilities/ErrorsTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php` Expected: FAIL. - [ ] **Step 3: `Errors::inUse()` erweitern** ```php /** * @param list> $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 '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 $items */ private function plan(PresetBook $before, array $items): DesignPlan|WP_Error { $book = $before; $errors = []; $targets = []; foreach ($items as $index => $item) { $field = sprintf('items[%d]', $index); try { $scope = PresetScope::fromInput(is_array($item) ? $item : [], $field); $found = $before->find($scope, is_string($item['preset'] ?? null) ? $item['preset'] : ''); } catch (InvalidArgumentException $e) { $errors[$field] = $e->getMessage(); continue; } if ($found === null) { $errors[$field . '.preset'] = sprintf('unbekanntes %s „%s“', $scope->label(), (string) $item['preset']); continue; } $id = (string) $found['id']; $targets[] = ['scope' => $scope, 'id' => $id, 'default' => $before->defaultId($scope) === $id]; $book = $book->without($scope, $id); } if ($errors !== []) { return Errors::invalidInput('Ungültige Angaben; nichts wurde gelöscht.', $errors); } $deleted = array_column($targets, 'id'); $nested = []; foreach ($targets as $target) { foreach ($before->nestedUsers($target['id']) as $user) { if (! in_array($user['id'], $deleted, true)) { $nested[] = ['type' => 'preset', 'preset_id' => $user['id'], 'name' => $user['name'], 'scope' => $user['scope']->name]; } } } $report = $this->usages->reportFor(new PresetUsageQuery($targets), false); if ($nested !== [] || $report->exists()) { return Errors::inUse( 'Die Presets werden noch verwendet; nichts wurde gelöscht.', array_merge($nested, $report->listing()['affected']), self::DETACH_HINT ); } $diff = PresetDiff::between($before, $book); return new DesignPlan($book->data(), $diff, PresetDiff::summary($diff), null, [], true); } } ``` Bei `PresetScope::fromInput()`-Fehlern steht der Pfad schon in der Meldung (`items[0]: …`); hier wird die ganze Meldung unter `items[i]` abgelegt – im Test reicht der Code `validation_failed`. In `DiviIntegration::abilities()` ergänzen: `new DiviPresetsDeleteAbility($this->designWriter, $this->designChange(), $this->globalUsages()),`; Namensliste in `DiviIntegrationTest` um `'lit/divi-presets-delete',`. Der Hinweis nennt `lit/divi-design-detach`, das erst mit 4b registriert wird; `ToolNamingTest` erlaubt das (er prüft nur die Schreibweise direkter und indirekter Tools). - [ ] **Step 5: Tests laufen lassen – sie bestehen** Run: `php vendor/bin/phpunit tests/Unit/Abilities/ErrorsTest.php tests/Unit/Integrations/Divi tests/Unit/Guides/ToolNamingTest.php` Expected: PASS. - [ ] **Step 6: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` ```bash git add src/Abilities/Errors.php src/Integrations/Divi/Design/DiviPresetsDeleteAbility.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Abilities/ErrorsTest.php tests/Unit/Integrations/Divi/Design/DiviPresetsDeleteAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php git commit -m "feat: lit/divi-presets-delete – unbenutzte Presets löschen" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### 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 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 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 '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 'object', 'properties' => ['snapshot_id' => ['type' => 'string']] + DesignChange::inputProperties(), 'required' => ['snapshot_id'], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return DesignChange::outputSchema(); } public function annotations(): array { return Annotations::destructive(false); } public function isDirect(): bool { return false; } public function permission(array $input): bool|WP_Error { return DesignRights::presets($this->writer) ?? true; } public function execute(array $input): mixed { $refusal = DesignRights::presets($this->writer); if ($refusal !== null) { return $refusal; } $id = is_string($input['snapshot_id'] ?? null) ? $input['snapshot_id'] : ''; if (preg_match(self::ID, $id, $match) !== 1) { return Errors::invalidInput('Ungültige Snapshot-ID.', ['snapshot_id' => 'z. B. presets-20261016T101500Z-a1b2c3']); } if ($match[1] !== 'presets') { return Errors::unsupported( 'Snapshots dieses Bereichs lassen sich noch nicht wiederherstellen.', 'Farben und Variablen folgen mit Etappe 4c, Theme-Optionen mit 4d.' ); } $snapshot = $this->snapshots->get('presets', $id); if ($snapshot === null) { return Errors::notFound( sprintf('Snapshot %s gibt es nicht (mehr).', $id), 'Vorhandene Snapshots mit lit/divi-design-snapshots auflisten; je Bereich bleiben die letzten 20.' ); } return $this->change->run( $this->name(), new PresetArea($this->writer), $input, static function (array $raw) use ($snapshot, $id): DesignPlan { $before = PresetBook::fromDivi($raw); $after = PresetBook::fromDivi($snapshot['data']); $diff = PresetDiff::between($before, $after); return new DesignPlan( $after->data(), $diff, sprintf('Snapshot %s wiederherstellen – %s', $id, PresetDiff::summary($diff)), null, ['Seiten, die Presets verwenden, die es im Snapshot nicht gibt, behalten den Verweis; das Modul ' . 'fällt dann auf das Standard-Preset zurück.'], true ); } ); } } ``` In `DiviIntegration::abilities()` ergänzen: ```php new DiviDesignSnapshotsAbility(new SnapshotStore()), new DiviDesignRestoreAbility($this->designWriter, $this->designChange(), new SnapshotStore()), ``` Namensliste in `DiviIntegrationTest` um `'lit/divi-design-snapshots', 'lit/divi-design-restore',` ergänzen. - [ ] **Step 5: Tests laufen lassen – sie bestehen** Run: `php vendor/bin/phpunit tests/Unit/Integrations/Divi tests/Unit/Guides/ToolNamingTest.php` Expected: PASS. - [ ] **Step 6: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` ```bash git add src/Integrations/Divi/Design/DiviDesignSnapshotsAbility.php src/Integrations/Divi/Design/DiviDesignRestoreAbility.php src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/DiviDesignSnapshotsAbilityTest.php tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php git commit -m "feat: lit/divi-design-snapshots und lit/divi-design-restore (Bereich presets)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 14: Guide, Ende-zu-Ende-Test gegen echtes Divi, Entscheidungsnotiz **Files:** - Modify: `docs/guides/divi-design-system.md` - Create: `tests/Integration/divi-presets-smoke.sh` - Modify: `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` **Interfaces:** - Consumes: alle Tools aus Tasks 10–13; `tests/Integration/lib.sh` (`connect`, `ability`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`). - Produces: Smoke `divi-presets-smoke.sh`, der grün endet mit `ALL CHECKS PASSED`. - [ ] **Step 1: Guide ergänzen** In `docs/guides/divi-design-system.md` nach den Regeln anhängen: ```markdown ## Presets anlegen, ändern, löschen Über `mcp-adapter-execute-ability`: - `lit/divi-preset-get` – ein Preset mit Einstellungen (`attrs` als Pfad → Wert wie bei `lit-divi-node-get`), verschachtelten Gruppen-Presets und den Seiten, die es verwenden (`usage`). - `lit/divi-presets-set` – `items` mit `module` (z. B. `"button"`) oder `group` (z. B. `"font-body"`): - neu: `name` + Einstellungen; ändern: `preset` (Name oder ID), optional `rename`; - Einstellungen in `attrs` (Vollform, Platzhalter `{{color:…}}`, `{{var:…}}`, `{{font:…}}`) oder aus einem gestalteten Modul: `from_node` = `{"post_id": 12, "id": "s0.r0.c1.m2"}` (Pfad-ID aus `lit-divi-outline`); - Presets tragen nur Gestaltung – Texte, Links und Bilder gehören ins Modul (sonst `validation_failed`); - `group_presets` (nur Modul-Presets), z. B. `{"font": "Button-Schrift"}`; `make_default: true` macht das Preset zum Standard seines Moduls bzw. seiner Gruppe; - neue Gruppen-Presets brauchen das Modul, aus dem die Gruppe stammt: `source_module` oder `from_node`. - `lit/divi-presets-delete` – nur Presets, die nirgends verwendet werden; sonst `in_use` mit den Fundstellen. - `lit/divi-design-snapshots` / `lit/divi-design-restore` – vor jeder Änderung legt LIT MCP einen Snapshot an (je Bereich die letzten 20); wiederherstellen mit `snapshot_id`. Ablauf jeder Änderung: Aufruf ohne `confirm` = Probelauf (`confirm_required` mit `diff`, betroffenen Seiten und `design_rev`), dem Nutzer die Änderung zeigen, dann derselbe Aufruf mit `confirm: true` und `design_rev`. Hat sich der Bestand inzwischen geändert (z. B. im Divi-Builder), kommt `rev_mismatch` – neu prüfen. Rechte: Presets ändern verlangt das Recht, das Design anzupassen, und in Divis Rollen-Editor den Visual Builder. ``` - [ ] **Step 2: Smoke-Skript schreiben** `tests/Integration/divi-presets-smoke.sh`: ```bash #!/usr/bin/env bash # End-to-end checks of Etappe 4a (presets: read, create, change, default, delete, snapshots, restore) through the # OAuth-protected endpoint against the real Divi. Runs INSIDE the DDEV web container: # cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-smoke.sh # Creates presets named "LIT Smoke …" 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="" \ --porcelain) HTML=$(wp eval "echo apply_filters('the_content', get_post($PAGE_ID)->post_content);") echo "$HTML" | grep -q "preset--module--divi-button--$ID" || fail "rendered page lacks the preset class" pass "page renders preset class" # 4. Deleting a used preset is refused. DEL=$(jq -cn --arg p "$ID" '{items: [{module: "button", preset: $p}]}') [ "$(error_code "$(ability lit/divi-presets-delete "$DEL")")" = "in_use" ] || fail "delete of a used preset" pass "used preset not deletable" # 5. Stale design_rev. STALE=$(ability lit/divi-presets-set "$(echo "$NEW" | jq -c '.items[0].name += " 2" | . + {confirm: true, design_rev: "veraltet"}')") [ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale design_rev: $STALE" pass "stale design_rev refused" # 6. Restore the snapshot taken before step 1: the preset is gone again. DRY=$(ability lit/divi-design-restore "$(jq -cn --arg s "$SNAPSHOT" '{snapshot_id: $s}')") [ "$(error_code "$DRY")" = "confirm_required" ] || fail "restore dry run: $DRY" REV=$(echo "$DRY" | jq -r '.__error | fromjson | .design_rev') RESTORED=$(ability lit/divi-design-restore "$(jq -cn --arg s "$SNAPSHOT" --arg r "$REV" '{snapshot_id: $s, confirm: true, design_rev: $r}')") [ "$(echo "$RESTORED" | jq -r '.ok')" = "true" ] || fail "restore: $RESTORED" wp eval "\$d = ET\Builder\Packages\GlobalData\GlobalPreset::get_data(); exit(isset(\$d['module']['divi/button']['items']['$ID']) ? 1 : 0);" \ || fail "restored inventory still has $ID" pass "snapshot restored" finish ``` Die Helfer `cleanup_app_passwords_for`, `guard_exit`, `pass`, `fail`, `finish` stammen aus `lib.sh`; der Trap folgt dem Muster aus `divi-library-smoke.sh` (`guard_exit` macht jedes Ende ohne `finish` zum Fehler). Der Preset-Bestand wird direkt in Divis Option zurückgeschrieben – unabhängig davon, ob das Plugin noch funktioniert. - [ ] **Step 3: Smoke laufen lassen** Run: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-presets-smoke.sh` Expected: sechs `ok - …`-Zeilen und `ALL CHECKS PASSED`. Danach prüfen, dass der Preset-Bestand wieder dem Stand vor dem Lauf entspricht: `ddev wp eval 'echo count(ET\Builder\Packages\GlobalData\GlobalPreset::get_data()["module"]["divi/button"]["items"] ?? []);'` (gleiche Zahl wie vor dem Lauf). Scheitert Schritt 3 (Preset-Klasse fehlt), mit `ddev wp eval` die gerenderte Ausgabe ansehen und mit einer im Builder angelegten Fixture-Seite vergleichen (`[Fixture] Designsystem`, Seite 3182) – nicht den Test lockern. - [ ] **Step 4: Entscheidungsnotiz nachführen** In `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` am Ende ergänzen: ```markdown ## Umsetzung 4a (Plan 2026-10-16-etappe-4a-presets.md) - Pipeline `DesignChange` (alle Bereiche), Snapshots `SnapshotStore`, Bereich `presets`. - Tools: lit/divi-preset-get, lit/divi-presets-set, lit/divi-presets-delete, lit/divi-design-snapshots, lit/divi-design-restore (alle indirekt). - Schreiben über die Schrittfolge von `GlobalPresetController::sync` (Mirror `preset-sync`). - Offen für 4b: Fundstellen von Standard-Gruppen-Presets (Gruppenliste je Modul), lit/divi-design-detach. ``` - [ ] **Step 5: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` ```bash git add docs/guides/divi-design-system.md tests/Integration/divi-presets-smoke.sh docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md git commit -m "test: Ende-zu-Ende-Test der Presets gegen Divi; Guide und Entscheidungsnotiz zu 4a" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ## 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).