# Etappe 4d – Designsystem: Theme-Optionen und Schriften – 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 und ändert die Design-Theme-Optionen von Divi (Schriften, Größen, Layout – `lit/divi-theme-options-get`, `lit/divi-theme-options-update`), lädt eigene Schriftdateien von einer URL hoch (`lit/divi-fonts-upload`), bekommt bei unbekannten Schriftnamen Vorschläge (auch bei Schrift-Variablen), und kann Snapshots des Bereichs `theme_options` wiederherstellen (`lit/divi-design-restore`) – mit Probelauf, `confirm`, Snapshot (Theme-Optionen) und Zurücklesen. **Architecture:** Die 4a-Pipeline `DesignChange` bleibt unverändert; neu sind die `DesignArea` `ThemeOptionsArea` mit reinen Klassen (`ThemeOptionSpec`, `ThemeOptionBook`, `ThemeOptionDiff`, `FontSuggest`) und drei kleinen Divi-Adaptern (`ThemeOptionsAdapter`, `FontCatalog`, `FontInstaller`) mit eigenen Fähigkeiten (`theme-option-read`, `theme-option-write`, `font-upload`). Divis Customizer-Standards und -Bereiche stehen nur als Literale in `et_divi_customizer_theme_settings()`; sie werden als Tabelle (`ThemeOptionSpec`) nachgebaut und über einen Mirror mit Fingerabdruck abgesichert. Der Font-Upload ist KEIN Pipeline-Bereich (keine Snapshots, Spec A2): eigener Probelauf/`confirm`-Ablauf, Download über einen aus `MediaImporter` herausgelösten, geteilten `RemoteFileFetcher`, Installation über Divis `FontsUtility::font_add()` im Prozess (`action: wp_handle_upload_rest`, wie Divis eigener KI-Pfad). **Tech Stack:** PHP ≥ 8.1, WordPress 7.x, Divi 5.15.1 (`et_update_option`/`et_delete_option`, Customizer-Sanitizer, `et_builder_get_fonts`/`_google_fonts`/`_websafe_fonts`/`_custom_fonts`, `FontsUtility::font_add/font_remove`, `HooksRegistration::check_filetype_and_ext_font`, `et_pb_is_allowed` – nur über die drei neuen Adapter), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq-Smokes. **Spec:** `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` (§3, §4, §5, §8, §10, §11, §13, §14 Schritt 7), Entscheidungen `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` (G3, G6, G10, A1, A2, A8, A9 – verbindlich, soweit unten nicht durch Rulings W1–W14 geändert), Datenformate `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` §4 und der Nachtrag 4d in Task 11, Recherche 5.15.1: `docs/superpowers/notes/2026-10-19-etappe-4d-divi-recherche.md` (Task 11 legt sie ab; bis dahin die Kopie `…/scratchpad/research-4d-divi.md` des Planers – die Task-Autoren haben sie gelesen), Plan 4c (`2026-10-19-etappe-4c-farben-variablen.md`) als Vorbild; der Code ist maßgeblich, wo er von den Plänen abweicht. **Voraussetzung:** Branch `etappe-4` (= `main`), Stand 2307027 (4a, 4b, 4c fertig), 3121 Unit-Tests grün (`composer test`), `composer lint` sauber. **Teil von Etappe 4:** 4a, 4b, 4c (fertig) → **4d (dieser Plan)** → 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 stellen im EXIT-Trap alles wieder her, was sie ändern (`et_divi` byte-genau über serialize/base64, hochgeladene Schriften inkl. Dateien über `FontsUtility::font_remove`, Seiten, Variablen). - PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9; Divi 5.15.1. Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, jede Datei mit `defined('ABSPATH') || exit;`, `declare(strict_types=1);`, höchstens 120 Zeichen je Zeile. Nach jedem Task `composer test` und `composer lint` sauber (`composer test` = `php -d memory_limit=512M vendor/bin/phpunit`). - **Divi-Grenze (Divi-Absicherung §3):** Divi-Aufrufe nur in Klassen aus `DiviAdapters::CLASSES` mit Manifest; jedes Divi-Symbol steht mit Fähigkeiten im Manifest; `DiviBoundaryTest` und `DiviFeatureAssignmentTest` bleiben grün (die angepasste Tabelle gehört in denselben Task wie die Manifest-Änderung); neue Signaturen: `fingerprints.json` über `wp lit-mcp divi-check --update-reference` in DDEV neu erzeugen (Befehl und Prüfung wie in Task 2 des Plans 4c). Alle übrigen neuen Klassen sind **reines PHP** ohne WordPress- und Divi-Aufrufe; Ausnahmen nennt der Task ausdrücklich. - **Pipeline (Spec §4):** Theme-Optionen laufen durch `DesignChange::run()` (Rechte → Eingabe prüfen, alle Fehler gesammelt als `validation_failed` mit Feldpfaden → Probelauf mit `diff`, `design_rev`, `warnings` → mit `confirm: true` Sperre, Snapshot `theme_options`, Schreiben, Zurücklesen + `verify`, Protokoll). Der Font-Upload nutzt die Pipeline nicht (kein Snapshot, A2), hat aber Probelauf + `confirm`, Rechte vorab, Rollback und Protokoll. - **Werte:** Jeder Wert einer Theme-Option wird vom Plugin geprüft (`ThemeOptionSpec::check`: Typ, Bereich, Auswahl) UND durch Divis eigenen Customizer-Sanitizer des Schlüssels geschickt (`ThemeOptionsAdapter::sanitize`); `false` oder ein vom Eingabewert abweichendes Ergebnis ist ein Validierungsfehler (Divi würde `false` stillschweigend speichern bzw. runden). Die fünf Hauptfarben, Button-Standards (`all_buttons_*`), Logo und SEO bleiben außen (G3, A8). - **Rechte:** Theme-Optionen schreiben und Restore: `edit_theme_options` **und** `et_pb_is_allowed('theme_customizer')` (Divis Customizer-Regel); Lesen (`theme-options-get`): wie `lit/divi-design-system` (`edit_posts`). Font-Upload: `upload_files` **und** `edit_theme_options` **und** `et_pb_is_allowed('custom_fonts_management')` (Divis eigene Regel `CustomFontController::store_permission()` plus die Plugin-Regel; Spec §10 nannte irrtümlich `use_visual_builder`). - **Exposition:** Neue Tools sind indirekt (über `mcp-adapter-execute-ability`), Gruppe `divi`, Annotations wie die Preset-Tools (`write`/`destructive`); Texte nennen indirekte Tools `lit/` (ToolNamingTest); `docs/guides/overview.md` „Direkt sichtbar:“ bleibt unverändert; Guides folgen gesammelt mit 4e. - **Sprache:** Meldungen, Hinweise, Beschreibungen auf Deutsch mit echten Umlauten; neutral „KI-Assistent“, „MCP-Client“. - **Tests:** Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery, `assertToolError`); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen. Reine Klassen erben von `PHPUnit\Framework\TestCase`. TDD: Test zuerst, RED-Lauf, Code, GREEN. Adapter-Tests ersetzen Divi-Klassen per `Mockery::mock('alias:…')` mit `@runInSeparateProcess` (Vorbild `GlobalColorsAdapterTest`). Integration: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh` (läuft nur mit deaktivierten Plugins `postlos` und `asynclistry`; vorher Zustand mit `ddev exec wp plugin list` notieren und danach wiederherstellen). - **Kein clean-code-review-Skill je Task** (die globale CLAUDE.md-Regel zieht sonst die Antwort des Implementierers an sich); der Gesamtreview folgt am Ende (Task 11). - Commits: `git commit -m "" -m "Co-Authored-By: Claude "` mit dem Namen des ausführenden Modells; nur die Dateien des Tasks stagen. ## Entscheidungen für 4d (Rulings, verbindlich) - **W1 Drei Fähigkeiten:** `DiviFeature::ThemeOptionRead` (`theme-option-read`, „Theme-Optionen lesen“), `ThemeOptionWrite` (`theme-option-write`, „Theme-Optionen schreiben“, schreibend), `FontUpload` (`font-upload`, „Schriften hochladen“, schreibend). `ThemeOptionsArea::feature()` = `ThemeOptionWrite`. Der Mirror `customizer-typography` (Standards/Bereiche der 20 Settings) trägt `theme-option-read` und `-write`; die Schriftlisten-Aufrufe tragen `theme-option-write`, `font-upload` und `variable-write` (die Schrift-Variablen werden gegen dieselbe Liste geprüft; ein Bruch sperrt bewusst alle drei). - **W2 Whitelist (Spec §8.1, G3):** `heading_font`, `body_font`, `primary_nav_font`, `secondary_nav_font`, `heading_font_weight`, `body_font_weight`, `body_font_size`, `tablet_body_font_size`, `phone_body_font_size`, `body_header_size`, `tablet_header_font_size`, `phone_header_font_size`, `body_font_height`, `body_header_height`, `body_header_spacing`, `body_header_style`, `content_width`, `gutter_width`, `section_padding`, `row_padding`. Standards, Bereiche und Sanitizer-Namen je Schlüssel stehen in der Recherche (Tabelle A) und werden in `ThemeOptionSpec` als Literale nachgebaut; der Mirror auf `et_divi_customizer_theme_settings()` (`functions.php`) sichert sie. `shortcut:` der Mirror hasht die ganze Funktion und schlägt bei jeder Customizer-Änderung an; bei zu häufigem Anschlagen auf einen engeren Ausschnitt umstellen (im Code kommentieren). - **W3 Unset statt `'none'`:** Ein Schlüssel ist „nicht gesetzt“ = Divi-Standard. Der Eingabewert `null` setzt zurück (Divi: `et_delete_option`); `'none'` und `false` werden abgelehnt (Divi speichert die Standardschrift als `false`, ein gespeichertes `'none'` wäre in Divi 5 ein echter Schriftname). Tablet-/Phone-Größen erben, solange sie nicht gesetzt sind (effektiver Standard = der größere Wert); `get` nennt den effektiven Wert und `default`. - **W4 Bereiche prüft das Plugin:** Divis Sanitizer (`absint`, `intval`, `floatval`, Auswahl-Sanitizer → `false`) klemmen nicht; `ThemeOptionSpec` prüft min/max/step/choices der Customizer-Controls. `body_header_style` nimmt genau einen Wert (`italic`, `uppercase`, `underline`) oder `null` (Divis Sanitizer lehnt Kombinationen ab). Zahlen dürfen als Zahl oder Ziffern-String kommen; gespeichert wird der Typ, den Divis Sanitizer liefert (int/float/String). - **W5 Schriftnamen:** gültig ist der exakte Familienname (Groß-/Kleinschreibung) aus `array_merge(et_builder_get_custom_fonts(), et_builder_get_fonts())` (so prüft Divis Sanitizer). Unbekannt → `validation_failed` mit bis zu 5 ähnlichen Namen (`FontSuggest`, ohne Beachtung der Groß-/Kleinschreibung: Präfix, Teilstring, `levenshtein`). Die Liste wird je Request einmal geladen (`et_builder_get_google_fonts()` lädt 1,3 MB und kann einmal je Tag einen HTTP-Abruf auslösen). Gilt für `heading_font`, `body_font`, `primary_nav_font`, `secondary_nav_font` und Variablen vom Typ `fonts`. Schriftnamen in Preset-/Modul-Attributen werden in 4d NICHT geprüft (Folgepunkt für 4e, in der Notiz festgehalten). - **W6 Snapshot `theme_options`:** `raw` = genau die vorhandenen Whitelist-Schlüssel aus `get_option('et_divi')` mit Rohwert und Typ (`array`); ein fehlender Schlüssel fehlt im Array. `ThemeOptionBook` normalisiert (nur Whitelist, Schlüsselreihenfolge fest) und liefert `rev()`. Schreiben/Restore: Schlüssel im neuen Bestand → `et_update_option`, Whitelist-Schlüssel, die im neuen Bestand fehlen → `et_delete_option`; danach die Caches wie ein Customizer-Speichern (`ET_Core_PageResource::remove_static_resources('all', 'all')` und `et_pb_force_regenerate_templates()`). Zurücklesen über `get_option('et_divi')` (nicht über `GlobalData::get_customizer_fonts()`, das `static` cacht). Restore ist ein vollständiges Ersetzen der Whitelist (`mayRemove = true`). Lost-Update-Risiko von `et_update_option` (schreibt das ganze Array aus dem Request-Global) ist dokumentiert; `design_rev` und Sperre mindern es. - **W7 Keine Fundstellen:** Theme-Optionen haben keinen Seitenbezug; der Probelauf liefert kein `affected` und die Warnung „wirkt auf die gesamte Website“ (bei Schriften zusätzlich „wirkt auch auf den Builder“). `DesignPlan::usages` bleibt `null`. - **W8 Font-Upload-Weg:** `FontInstaller::install()` ruft `FontsUtility::font_add($files, $name, $settings, ['action' => 'wp_handle_upload_rest'])` mit Temp-Dateien (`wp_tempnam`, wie `AiAgentImageUtility::install_to_custom_fonts()`); jede Datei vorab durch `HooksRegistration::check_filetype_and_ext_font()`; nach `font_add` werden die gelieferten `font_file`-Schlüssel gegen die Eingabe verglichen (Divi meldet Teilerfolg als Erfolg) – Abweichung → `FontsUtility::font_remove($name)` und Fehler. Statt eines Nachbaus des `et_uploaded_fonts`-Eintrags (Spec §8.2) ein Mirror `font-install` auf `AiAgentImageUtility::install_to_custom_fonts` (Aufrufkonvention). Erlaubte Formate: `woff2`, `woff`, `ttf`, `otf` (kein `eot`); Gewichte `all` oder Liste aus `100…900` (Plugin prüft, Divi nicht); `generic_family` aus `serif|sans-serif|monospace|cursive|fantasy` (Standard `sans-serif`; Divi prüft nicht). - **W9 Font-Namen und -Dateien:** Name: 1–100 Zeichen, nur `A–Z a–z 0–9`, Leerzeichen, `_`, `-` (Divi entfernt andere Zeichen stillschweigend – das Plugin lehnt ab und nennt den bereinigten Namen), nicht in Googles, Web-Safe- oder bereits hochgeladenen Schriften (ohne Beachtung der Groß-/Kleinschreibung; strenger als Divi). Dateien: höchstens `min(Limits::uploadBytes(), 5 MB)` je Datei (A9), 1–4 Dateien (je Format höchstens eine), Magic Bytes (`FontSignature`: `wOF2`, `wOFF`, `00 01 00 00`/`true` = ttf, `OTTO` = otf) müssen zur Endung der Eingabe passen; URL-Regeln (nur öffentliche Hosts, Weiterleitungen geprüft, Zeitlimit) wie `lit/media-upload-from-url` über den geteilten `RemoteFileFetcher`. - **W10 Probelauf des Uploads:** ohne `confirm` lädt das Tool die Dateien wirklich herunter (nur Temp-Dateien, danach gelöscht), prüft alles und antwortet mit `Errors::confirmRequired` (Name, Dateien mit Format/Größe/SHA-256, Gewichte, Familie, „wirkt auf …“-Hinweis). Mit `confirm: true` lädt es erneut, prüft erneut und installiert; das Protokoll nennt die SHA-256-Werte. Kein Snapshot; Rollback bei Fehler über `font_remove`. - **W11 Restore:** `DiviDesignRestoreAbility` bekommt `ThemeOptionsRestorePlanner` (`area()` `theme_options`); die Meldung „Theme-Optionen folgen mit Etappe 4d“ entfällt (ein unbekannter Bereich bleibt `unsupported`); Rechte und Fähigkeiten wie beim Schreiben. - **W12 Schrift-Variablen:** `DiviVariablesSetAbility` prüft Variablen vom Typ `fonts` zusätzlich gegen `FontCatalog` (W5); `VariableValue` bleibt rein (die Listenprüfung liegt in der Ability, `VariableValue.php:12` „folgt mit 4d“ wird berichtigt). - **W13 Divi-Beschränkungen als Grenzen dokumentieren:** `et_get_option`-/`et_update_option`-Lost-Update, `body_header_style`-Einzelwert, fehlende Bereichsprüfung in Divi, `font_add`-Teilerfolg, `remove_static_resources` wirkungslos ohne angemeldeten Benutzer (der Prozess hat einen, Smokes laufen mit `--user`). - **W14 Rechte-Texte:** `DesignRights::themeOptions(DesignSystemWriter, ThemeOptionsAdapter)` und `DesignRights::fonts(FontCatalog)` entsprechen den Rechten oben; Fehlertexte nennen das fehlende Recht und die Divi-Rolle („Theme-Anpassung“/„Eigene Schriften verwalten“). ## Schnittstellenvertrag (Namen und Signaturen, die spätere Tasks voraussetzen) Alle Klassen unter `src/Integrations/Divi/Design/` (Namespace `LIT\MCP\Integrations\Divi\Design`), Tests unter `tests/Unit/Integrations/Divi/Design/` (Namespace `LIT\MCP\Tests\Unit\Integrations\Divi\Design`), sofern nicht anders genannt. Task-Autoren dürfen Details verfeinern, aber nur mit Eintrag unter `## ABWEICHUNGEN` am Ende ihres Abschnitts. ```php // Release/DiviFeature.php (Task 1) case ThemeOptionRead = 'theme-option-read'; // label 'Theme-Optionen lesen' case ThemeOptionWrite = 'theme-option-write'; // label 'Theme-Optionen schreiben', isWrite() = true case FontUpload = 'font-upload'; // label 'Schriften hochladen', isWrite() = true // ThemeOptionsAdapter.php (Task 2) – class (nicht final) implements DiviAdapter, Manifest::DESIGN public static function manifest(): Manifest; /** @return array vorhandene Whitelist-Schlüssel aus get_option('et_divi') */ public function read(): array; /** Divis Customizer-Sanitizer des Schlüssels; false = Divi lehnt ab. @throws InvalidArgumentException unbekannter Schlüssel */ public function sanitize(string $key, mixed $value): mixed; /** @param array $set @param list $unset @throws RuntimeException wenn das Zurücklesen nicht stimmt */ public function write(array $set, array $unset): array; // et_update_option / et_delete_option, dann Caches, liefert read() public function canCustomize(): bool; // et_pb_is_allowed('theme_customizer'), fail closed ohne Divi // FontCatalog.php (Task 2) – class (nicht final) implements DiviAdapter /** @return list exakte Familiennamen: hochgeladene + Google + Web-Safe, einmal je Request */ public function names(): array; /** @return list */ public function uploaded(): array; /** @return list Google + Web-Safe + hochgeladen (Kollisionsprüfung beim Upload) */ public function reserved(): array; public function has(string $name): bool; public function canManage(): bool; // et_pb_is_allowed('custom_fonts_management') // FontInstaller.php (Task 8) – class (nicht final) implements DiviAdapter /** @return list Formate, die Plugin UND Divi (mime_types_font) erlauben: woff2, woff, ttf, otf */ public function formats(): array; public function accepts(string $path, string $filename): bool; // HooksRegistration::check_filetype_and_ext_font /** @param array $files Endung => Temp-Dateipfad @return list installierte Endungen @throws RuntimeException */ public function install(string $name, array $files, string $weights, string $family): array; public function remove(string $name): void; // DesignRights.php (Task 2) – zusätzlich public static function themeOptions(DesignSystemWriter $writer, ThemeOptionsAdapter $options): ?WP_Error; public static function fonts(FontCatalog $fonts): ?WP_Error; // upload_files + edit_theme_options + custom_fonts_management // ThemeOptionSpec.php (Task 3) – reines PHP public const KEYS = [ /* die 20 Schlüssel in der Reihenfolge von W2 */ ]; /** @return array{type: 'int'|'float'|'font'|'weight'|'style', label: string, default: int|float|string|null, min?: int|float, max?: int|float, step?: int|float, choices?: list, inherits?: string} */ public static function definition(string $key): array; public static function isKey(string $key): bool; public static function check(string $key, mixed $value): ?string; // null = gültig (Typ/Bereich/Auswahl, ohne Schriftliste), sonst deutsche Meldung public static function normalize(string $key, mixed $value): int|float|string; // "15" → 15, "1.7" → 1.7 public static function effective(string $key, array $stored): int|float|string|null; // gespeicherter Wert, sonst vererbter/Standardwert (W3) // ThemeOptionBook.php (Task 3) – reines PHP, unveränderlich public static function fromDivi(array $raw): self; // nur Whitelist, feste Reihenfolge /** @return array */ public function data(): array; public function rev(): string; // Rev::of wie PresetBook/ColorBook public function has(string $key): bool; public function get(string $key): int|float|string|false|null; public function with(string $key, int|float|string|false $value): self; public function without(string $key): self; // ThemeOptionDiff.php (Task 3) – reines PHP /** @return list action: added|changed|reset */ public static function between(ThemeOptionBook $before, ThemeOptionBook $after): array; public static function summary(array $diff): string; // 'Theme-Optionen: 1 gesetzt, 2 geändert, 1 zurückgesetzt.' / '… keine Änderung.' // FontSuggest.php (Task 3) – reines PHP /** @param list $names @return list höchstens $limit ähnliche Namen */ public static function similar(string $name, array $names, int $limit = 5): array; // ThemeOptionsArea.php (Task 4) – implements DesignArea: name 'theme_options', label 'Theme-Optionen', feature ThemeOptionWrite public function __construct(ThemeOptionsAdapter $options); // DiviThemeOptionsGetAbility.php: __construct(ThemeOptionsAdapter, ?FontCatalog = null) – lit/divi-theme-options-get // DiviThemeOptionsUpdateAbility.php: __construct(DesignSystemWriter, ThemeOptionsAdapter, FontCatalog, DesignChange) – lit/divi-theme-options-update // RemoteFileFetcher.php (Task 7) – src/Integrations/Media/RemoteFileFetcher.php (Namespace LIT\MCP\Integrations\Media) – aus MediaImporter herausgelöst // FontSignature.php (Task 8) – reines PHP: public static function detect(string $header): ?string; // 'woff2'|'woff'|'ttf'|'otf' // FontNameRules.php (Task 8) – reines PHP: public static function check(string $name, array $reserved): ?string; // null = gültig // DiviFontsUploadAbility.php (Task 8): __construct(FontCatalog, FontInstaller, RemoteFileFetcher, ChangeLog, …) – lit/divi-fonts-upload // RestorePlanner (Task 6) – ThemeOptionsRestorePlanner implements RestorePlanner (area 'theme_options') ``` ## Dateistruktur **Neu (`src/Integrations/Divi/Design/`):** `ThemeOptionsAdapter`, `FontCatalog`, `FontInstaller`, `ThemeOptionSpec`, `ThemeOptionBook`, `ThemeOptionDiff`, `FontSuggest`, `ThemeOptionsArea`, `DiviThemeOptionsGetAbility`, `DiviThemeOptionsUpdateAbility`, `ThemeOptionsRestorePlanner`, `FontSignature`, `FontNameRules`, `DiviFontsUploadAbility`; **neu** `src/Integrations/Media/RemoteFileFetcher.php`. **Geändert:** `Release/DiviFeature.php`, `Release/DiviAdapters.php`, `Release/fingerprints.json`, `Design/DesignRights.php`, `Design/DiviDesignRestoreAbility.php`, `Design/DiviVariablesSetAbility.php`, `Design/VariableValue.php` (nur Kommentar), `src/Integrations/Media/MediaImporter.php`, `DiviIntegration.php`, Notizen/Spec (Task 11). **Neu (Tests):** je Klasse eine Testdatei; `tests/Integration/divi-theme-fonts-smoke.sh`; ggf. eine kleine Schrift-Fixture unter `tests/Fixtures/`. **Geändert (Tests):** `DiviFeatureTest`, `DiviFeatureAssignmentTest`, `DiviToolFeaturesTest`, `DiviIntegrationTest`, `DiviDesignRestoreAbilityTest`, `DesignRightsTest`, `DiviVariablesSetAbilityTest`, `MediaImporterTest` (und was `grep -rn "PresetWrite\|preset-write\|VariableWrite\|variable-write" tests` an Fähigkeitszählungen findet). ## Task-Übersicht | # | Task | Ergebnis | |---|---|---| | 1 | Fähigkeiten `ThemeOptionRead`/`ThemeOptionWrite`/`FontUpload` | Enum, Label, Tests (Commit gemeinsam mit Task 2) | | 2 | `ThemeOptionsAdapter`, `FontCatalog`, Manifest, Fingerabdrücke, `DesignRights` | Divi-Grenze für Lesen/Schreiben/Schriftlisten | | 3 | `ThemeOptionSpec`, `ThemeOptionBook`, `ThemeOptionDiff`, `FontSuggest` | reiner Bestand der Theme-Optionen | | 4 | `ThemeOptionsArea`, `lit/divi-theme-options-get`, `lit/divi-theme-options-update` | Theme-Optionen lesen/schreiben | | 5 | Schriftprüfung der Schrift-Variablen | `lit/divi-variables-set` prüft Typ `fonts` | | 6 | `ThemeOptionsRestorePlanner` | Restore für `theme_options` | | 7 | `RemoteFileFetcher` aus `MediaImporter` herauslösen | geteilter Download (Refactor, Verhalten gleich) | | 8 | `FontSignature`, `FontNameRules`, `FontInstaller`, `lit/divi-fonts-upload` | Schriften hochladen | | 9 | Verdrahtung | `DiviIntegration`, Tabellen der Fähigkeiten | | 10 | Smoke gegen echtes Divi | `divi-theme-fonts-smoke.sh` | | 11 | Notizen, Recherche ablegen, Spec, Gesamtverifikation | Formate-Nachtrag 4d, Spec §8/§10, Review, Memory | --- ### Task 1: Fähigkeiten `ThemeOptionRead`, `ThemeOptionWrite` und `FontUpload` (W1) Drei neue Fähigkeiten, damit ein Bruch des Customizer-Nachbaus, der Schriftlisten oder des Font-Uploads nur die jeweils betroffenen Tools sperrt. Geprüft mit `grep -rn "VariableWrite\|variable-write\|13 \|dreizehn\|thirteen" src tests docs` und einem Lauf der ganzen Suite mit den drei neuen Fällen (Stand 2307027, Kopie): nur drei Tests brechen – zwei in `DiviFeatureTest` (zählen und listen die Fälle exakt) und `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` (jede Fähigkeit braucht eine Manifest-Anforderung; die kommen erst mit den Adaptern in Task 2). `FeatureOverviewTest`, `DiviEnvironmentComponentTest`, `CompatibilityMatrixTest`, `ReleaseGateTest`, `DiviCheckCommandTest::testEveryFeatureGetsOneLine` und alle übrigen rechnen mit `count(DiviFeature::cases())` oder nennen nur einzelne Fähigkeiten und bleiben grün; `DiviToolFeaturesTest` nennt nur Tool-Tabellen (die neuen Tools folgen mit Task 9). Außerhalb der Unit-Tests zählt nur `tests/Integration/divi-release-smoke.sh` die Fähigkeiten (Liste der IDs, „thirteen“, `= "13"`); `docs/guides` nennt keine Zahl. **Wegen des Zuordnungstests hat Task 1 keinen eigenen Commit:** seine Dateien gehen mit dem Commit von Task 2 (Step 14) ins Repo (wie Tasks 1/2 des Plans 4c). Bis dahin ist genau `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` rot. **Files:** - Modify: `src/Integrations/Divi/Release/DiviFeature.php` (drei Fälle, `isWrite()`, `label()`, Docblock von `isWrite()`) - Modify: `tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php` - Modify: `tests/Integration/divi-release-smoke.sh` (16 statt 13 Fähigkeiten; läuft erst nach Task 2 grün, weil vorher die Signatur-Referenzen der neuen Aufrufe fehlen) **Interfaces:** - Consumes: `DiviFeature` (vorhanden). - Produces: `DiviFeature::ThemeOptionRead = 'theme-option-read'` („Theme-Optionen lesen“, `isWrite() === false`), `DiviFeature::ThemeOptionWrite = 'theme-option-write'` („Theme-Optionen schreiben“, `isWrite() === true`), `DiviFeature::FontUpload = 'font-upload'` („Schriften hochladen“, `isWrite() === true`). Reihenfolge der Fälle (bestimmt die Reihenfolge in Status-Tool, Admin-Reiter und `divi-check`): direkt nach `VariableWrite`, vor `LibraryRead`. - [ ] **Step 1: `DiviFeatureTest` auf sechzehn Fähigkeiten umstellen** `tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php` vollständig ersetzen: ```php 'page-read', 'PageWrite' => 'page-write', 'Catalog' => 'catalog', 'DynamicSources' => 'dynamic-sources', 'DesignRead' => 'design-read', 'PresetWrite' => 'preset-write', 'ColorWrite' => 'color-write', 'VariableWrite' => 'variable-write', 'ThemeOptionRead' => 'theme-option-read', 'ThemeOptionWrite' => 'theme-option-write', 'FontUpload' => 'font-upload', 'LibraryRead' => 'library-read', 'LibraryWrite' => 'library-write', 'Roles' => 'roles', 'ThemeBuilderRead' => 'tb-read', 'ThemeBuilderWrite' => 'tb-write', ], array_column(array_map( static fn (DiviFeature $feature): array => [$feature->name, $feature->value], DiviFeature::cases() ), 1, 0) ); } public function testOnlyTheEightWriteFeaturesWrite(): void { $writing = array_values(array_map( static fn (DiviFeature $feature): string => $feature->value, array_filter(DiviFeature::cases(), static fn (DiviFeature $feature): bool => $feature->isWrite()) )); self::assertSame( [ 'page-write', 'preset-write', 'color-write', 'variable-write', 'theme-option-write', 'font-upload', 'library-write', 'tb-write', ], $writing ); } public function testEveryFeatureHasAUniqueGermanLabel(): void { $labels = array_map(static fn (DiviFeature $feature): string => $feature->label(), DiviFeature::cases()); self::assertCount(count(DiviFeature::cases()), array_unique($labels)); self::assertSame('Seiten schreiben', DiviFeature::PageWrite->label()); self::assertSame('Theme Builder lesen', DiviFeature::ThemeBuilderRead->label()); self::assertSame('Farben schreiben', DiviFeature::ColorWrite->label()); self::assertSame('Variablen schreiben', DiviFeature::VariableWrite->label()); self::assertSame('Theme-Optionen lesen', DiviFeature::ThemeOptionRead->label()); self::assertSame('Theme-Optionen schreiben', DiviFeature::ThemeOptionWrite->label()); self::assertSame('Schriften hochladen', DiviFeature::FontUpload->label()); foreach ($labels as $label) { self::assertNotSame('', trim($label)); } } public function testValuesResolveBackToTheFeature(): void { self::assertSame(DiviFeature::ThemeBuilderWrite, DiviFeature::from('tb-write')); self::assertSame(DiviFeature::ThemeOptionWrite, DiviFeature::from('theme-option-write')); self::assertNull(DiviFeature::tryFrom('page')); } } ``` - [ ] **Step 2: RED** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviFeatureTest` Expected: FAIL – `Tests: 4, Assertions: 8, Errors: 2, Failures: 2`: - `testEveryFeatureHasAUniqueGermanLabel`: `Error: Undefined constant LIT\MCP\Integrations\Divi\Release\DiviFeature::ThemeOptionRead` - `testValuesResolveBackToTheFeature`: `Error: Undefined constant …DiviFeature::ThemeOptionWrite` - `testTheSixteenFeaturesAndTheirValues`: Array-Vergleich, es fehlen `'ThemeOptionRead' => 'theme-option-read'`, `'ThemeOptionWrite' => 'theme-option-write'`, `'FontUpload' => 'font-upload'` - `testOnlyTheEightWriteFeaturesWrite`: es fehlen `theme-option-write` und `font-upload` - [ ] **Step 3: Fälle, `isWrite()` und `label()` ergänzen** ```diff diff --git a/src/Integrations/Divi/Release/DiviFeature.php b/src/Integrations/Divi/Release/DiviFeature.php index 2880001..4ed32b3 100644 --- a/src/Integrations/Divi/Release/DiviFeature.php +++ b/src/Integrations/Divi/Release/DiviFeature.php @@ -23,18 +23,21 @@ enum DiviFeature: string case PresetWrite = 'preset-write'; case ColorWrite = 'color-write'; case VariableWrite = 'variable-write'; + case ThemeOptionRead = 'theme-option-read'; + case ThemeOptionWrite = 'theme-option-write'; + case FontUpload = 'font-upload'; case LibraryRead = 'library-read'; case LibraryWrite = 'library-write'; case Roles = 'roles'; case ThemeBuilderRead = 'tb-read'; case ThemeBuilderWrite = 'tb-write'; - /** Whether the feature changes data (the six *Write cases). */ + /** Whether the feature changes data (the seven *Write cases and FontUpload). */ public function isWrite(): bool { return match ($this) { - self::PageWrite, self::PresetWrite, self::ColorWrite, self::VariableWrite, self::LibraryWrite, - self::ThemeBuilderWrite => true, + self::PageWrite, self::PresetWrite, self::ColorWrite, self::VariableWrite, self::ThemeOptionWrite, + self::FontUpload, self::LibraryWrite, self::ThemeBuilderWrite => true, default => false, }; } @@ -51,6 +54,9 @@ enum DiviFeature: string self::PresetWrite => 'Presets schreiben', self::ColorWrite => 'Farben schreiben', self::VariableWrite => 'Variablen schreiben', + self::ThemeOptionRead => 'Theme-Optionen lesen', + self::ThemeOptionWrite => 'Theme-Optionen schreiben', + self::FontUpload => 'Schriften hochladen', self::LibraryRead => 'Bibliothek lesen', self::LibraryWrite => 'Bibliothek schreiben', self::Roles => 'Divi-Rollen', ``` - [ ] **Step 4: GREEN** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviFeatureTest` Expected: `OK (4 tests, 29 assertions)`. Run: `composer test` Expected: `Tests: 3121, …, Failures: 1` – genau `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` mit `Fähigkeiten ohne Anforderung in einem Manifest` und `+ 0 => 'theme-option-read'`, `+ 1 => 'theme-option-write'`, `+ 2 => 'font-upload'`. Jeder andere Fehlschlag ist ein Befund (ein Test zählt Fähigkeiten doch fest) und wird hier mit angepasst. - [ ] **Step 5: Integrations-Smoke auf sechzehn Fähigkeiten umstellen** ```diff diff --git a/tests/Integration/divi-release-smoke.sh b/tests/Integration/divi-release-smoke.sh index 35dd803..084ec5f 100755 --- a/tests/Integration/divi-release-smoke.sh +++ b/tests/Integration/divi-release-smoke.sh @@ -27,8 +27,8 @@ trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT check_pages() { wp post list --post_type=page --post_status=any --meta_key=_lit_mcp_check_page --format=count; } -# The ids of the thirteen features (plan decision D1, plan 4c V2), sorted. -FEATURE_IDS='["catalog","color-write","design-read","dynamic-sources","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write","variable-write"]' +# The ids of the sixteen features (plan decision D1, plan 4c V2, plan 4d W1), sorted. +FEATURE_IDS='["catalog","color-write","design-read","dynamic-sources","font-upload","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write","theme-option-read","theme-option-write","variable-write"]' # Adds a problem to one feature in the cached state, as if its mirror check had failed. Needs the cache entry of # ReleaseResolver (format 2: ["format" => 2, "key" => stateKey, "problems" => list of Problem::toArray()]); the key @@ -68,7 +68,7 @@ echo "$STATUS" | jq -e --argjson ids "$FEATURE_IDS" ' .components[] | select(.id == "divi") | .features | ([.[].id] | sort) == $ids and all(.state == "released" and .source == "code" and .problems == [])' >/dev/null \ - || fail "all thirteen features must be released from code without problems: $STATUS" + || fail "all sixteen features must be released from code without problems: $STATUS" echo "$STATUS" | jq -e ' .components[] | select(.id == "divi") | .features | (.[] | select(.id == "page-write") | .tools | any(. == "lit-divi-insert")) @@ -76,7 +76,7 @@ echo "$STATUS" | jq -e ' and (.[] | select(.id == "preset-write") | .tools | any(startswith("lit/divi-presets-"))) and (.[] | select(.id == "page-read") | .label == "Seiten lesen")' >/dev/null \ || fail "features must name their tools (direct lit-, indirect lit/): $STATUS" -pass "lit-site-status: thirteen features released (code), each with its tools" +pass "lit-site-status: sixteen features released (code), each with its tools" # 2. Admin check through the REST route (application password instead of the cookie nonce). # X-Forwarded-Proto: https is required here: without it the request to http://localhost with the @@ -212,8 +212,8 @@ simulate_problem page-write "Smoke: Cache vergiftet" CLI=$(wp --user="$ADMIN_LOGIN" lit-mcp divi-check 2>&1) || fail "wp lit-mcp divi-check must succeed (exit 0): $CLI" echo "$CLI" | grep -q '^Fähigkeiten:' || fail "divi-check must print the matrix: $CLI" echo "$CLI" | grep -q '✓ Seiten schreiben – freigegeben (code)' || fail "divi-check must show page-write released: $CLI" -[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "13" ] || fail "divi-check must show thirteen released features: $CLI" +[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "16" ] || fail "divi-check must show sixteen released features: $CLI" [ "$(check_pages)" = "0" ] || fail "the CLI check must delete its page" -pass "wp lit-mcp divi-check prints thirteen released features, recomputes the state and leaves no page" +pass "wp lit-mcp divi-check prints sixteen released features, recomputes the state and leaves no page" finish ``` Run: `bash -n tests/Integration/divi-release-smoke.sh && ! grep -n thirteen tests/Integration/divi-release-smoke.sh` Expected: keine Ausgabe, Exit 0 (Syntax gültig, kein „thirteen“ mehr). Die Liste ist sortiert (`jq 'sort == .'` → `true`). Der Smoke selbst läuft in Task 2, Step 13. - [ ] **Step 6: Lint, kein Commit** Run: `composer lint` Expected: sauber. Nicht committen: die drei Dateien gehen mit dem Commit von Task 2 (Step 14) ins Repo, weil `composer test` erst mit den Adaptern grün ist. --- ### Task 2: Adapter `ThemeOptionsAdapter` und `FontCatalog`, Manifest, Fingerabdrücke, `DesignRights` (W1, W2, W5, W6, W13, W14) Zwei kleine Adapter im Bereich „Designsystem“ – die einzigen Klassen, die Divis Theme-Optionen schreiben bzw. Divis Schriftlisten lesen – mit eigenem Manifest. Geteilte Divi-Aufrufe stehen wie in Plan 4c in **jedem** Manifest, das sie nutzt (`ET_Core_PageResource::remove_static_resources` zusätzlich zu `DiviPages`/`DiviThemeBuilder`, `et_pb_is_allowed` zusätzlich zu `DiviRoles`/`GlobalVariablesAdapter`); `DiviAdapters::calls()` führt jedes Symbol nur einmal (für die Signatur egal), `featuresOf()` gilt je Manifest. Geprüft an Divi 5.15.1 (`themes/Divi`): die `sanitize_callback` aller 20 Settings in `functions.php` (`et_divi[]`, je Setting gelesen – stimmt mit der Recherche, Tabelle A, überein); `et_sanitize_float_number($number)` (`includes/functions/sanitization.php:7`), `et_sanitize_int_number($number)` (`:16`), `et_sanitize_font_choices($choosen)` (`:59`, `array_merge(et_builder_get_custom_fonts(), et_builder_get_fonts())`, `isset` → case-sensitiv), `et_sanitize_global_font_weight($choosen)` (`:345`), `et_divi_global_font_style($choosen)` (`:358`) – alle 1 Parameter, untypisiert; `et_update_option($option_name, $new_value, …)` (`epanel/custom_functions.php:276`, 2 Pflicht-/6 Parameter, schreibt über das Request-Global `$et_theme_options`), `et_delete_option($option_name)` (`:320`), `ET_Core_PageResource::remove_static_resources($post_id, $owner = 'core', …)` (`core/components/PageResource.php:1398`, 1 Pflicht-/7 Parameter, wirkungslos ohne `edit_posts`), `et_pb_force_regenerate_templates()` (`includes/builder/core.php:3811`, auch auf `customize_save_after`), `et_pb_is_allowed($capabilities, $role = '')` (`:1136`), `et_builder_get_fonts($settings = array())` (`:4730`, ohne Google Fonts nur Web-Safe), `et_builder_get_websafe_fonts()` (`:4783`), `et_builder_get_custom_fonts()` (`:4822`, `(array)` gefiltert), `et_builder_get_google_fonts()` (`:4911`, `[]` bei abgeschalteten Google Fonts, ruft `et_builder_google_fonts_sync()`). Rollen-Editor: `theme_customizer` „Theme Customizer“ (`includes/builder/functions.php:9523`), `custom_fonts_management` „Upload/Remove Fonts“ (`:9595`). **Mirror auf `functions.php` (Theme-Wurzel, Funktion ohne Namespace):** wird ohne Codeänderung unterstützt. `Mirror::$file` ist relativ zum Theme-Verzeichnis (`DiviReference::current($themeDir)` → `$themeDir . '/' . $file`), `class: null` sucht die Funktion außerhalb jedes Klassenrumpfs; `SourceFingerprint::fromSource()` zählt `if (! function_exists(…)) :`-Blöcke nicht als Klassenrumpf. In der Kopie nachgerechnet: `SourceFingerprint::of('/functions.php', 'et_divi_customizer_theme_settings')` → Zeilen 1371–5332, 103 803 Byte Quelltext, Hash `0264fa5a2ba604542e1e1ad0c0339f04c8f4f67297087a0f92954ee63d343fd0`. Die Referenzquelle `tests/Fixtures/divi/reference/customizer-typography.txt` wird damit ~104 KB groß (W2-`shortcut:`, im Manifest kommentiert). **Sanitizer-Tabelle und Divi-Grenze:** `ThemeOptionsAdapter::SANITIZERS` (öffentliche Konstante, Schlüssel = die 20 Whitelist-Schlüssel in W2-Reihenfolge, Wert = Name des `sanitize_callback`) ist die eine Tabelle; `sanitize()` ruft den Namen dynamisch auf (`$sanitizer($value)`, wie WordPress selbst `sanitize_callback` ruft). Ehrlich bleibt das, weil `DiviTouches` auch String-Literale `'et_…'` als Divi-Namen meldet: ein neuer Divi-Sanitizer in der Tabelle ohne Manifest-Eintrag lässt `DiviBoundaryTest::testAdaptersDeclareExactlyTheDiviNamesTheyUse` scheitern; `absint` (WordPress) ist kein Divi-Name. Die Gegenrichtung („im Manifest, aber nicht benutzt“) prüft `DiviBoundaryTest` nur schwach (die Literale im Manifest selbst zählen als Benutzung) – darum pinnt `ThemeOptionsAdapterTest::testTheSanitizerTableCoversTheWhitelistWithDivisCallsOfTheManifest` die Tabelle: genau die 20 W2-Schlüssel, genau fünf Divi-Sanitizer, jeder als `Call(, 1)` im Manifest. `ThemeOptionSpec` (Task 3) soll seine `KEYS` gegen `array_keys(ThemeOptionsAdapter::SANITIZERS)` testen (siehe `## ABWEICHUNGEN`, B1). **Unit-Tests ohne Divi:** Alle Divi-Symbole der beiden Adapter sind Funktionen; die Tests ersetzen sie mit Brain Monkey (`Functions\when()`/`Functions\expect()`) und laufen dann mit `@runInSeparateProcess` (Brain Monkey definiert eine fehlende Funktion für den Rest des Prozesses; `DiviRolesTest`, `DiviIntegrationTest`, `GlobalVariablesAdapterTest::testWithoutDiviNobodyManagesVariables` und die neuen `testWithoutDivi…`-Tests verlassen sich auf ihr Fehlen). Ein `Mockery::mock('alias:…')` wie in `GlobalColorsAdapterTest` ist nicht nötig – es gibt keine Divi-Klasse außer `ET_Core_PageResource`, und die ist der aufzeichnende Stub `tests/Stubs/EtCorePageResource.php` (`$removed`, vom Bootstrap geladen). `get_option` wird wie in `GlobalColorsAdapterTest` ohne eigenen Prozess per `Functions\when()` ersetzt. Beide Adapter sind **nicht `final`**, damit `DesignRightsTest` und die Tasks 4–8 sie mit Mockery ersetzen können. **Files:** - Create: `src/Integrations/Divi/Design/ThemeOptionsAdapter.php` - Create: `src/Integrations/Divi/Design/FontCatalog.php` - Create: `tests/Unit/Integrations/Divi/Design/ThemeOptionsAdapterTest.php` - Create: `tests/Unit/Integrations/Divi/Design/FontCatalogTest.php` - Modify: `src/Integrations/Divi/Release/DiviAdapters.php` (beide Klassen nach `GlobalVariablesAdapter::class`) - Modify: `tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php` (gepinnte Tabelle, „twelve adapters“) - Modify: `tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php` (Stand-in für den neuen Nachbau, sieben statt sechs Fingerabdrücke) - Modify: `src/Integrations/Divi/Release/fingerprints.json` (generiert: ein Nachbau, zwölf `sig:`-Schlüssel) - Create: `tests/Fixtures/divi/reference/customizer-typography.txt` (generiert) - Modify: `src/Integrations/Divi/Design/DesignRights.php` (`themeOptions()`, `fonts()`, private `themeRight()`, Konstante `ROLE_EDITOR`) - Modify: `tests/Unit/Integrations/Divi/Design/DesignRightsTest.php` **Interfaces:** - Consumes: `Manifest`, `Call`, `Mirror`, `DiviAdapter`, `DiviAdapters::CLASSES`, `ReleaseGate::assertFeature()`, `DiviLocked`, `Errors::forbidden()`, `DesignSystemWriter` (alle vorhanden), `DiviFeature::ThemeOptionRead`/`ThemeOptionWrite`/`FontUpload` (Task 1); Tests: `LIT\MCP\Tests\Unit\TestCase` (`assertToolError`, Stub `absint`), `ReleaseMatrices::uniform(ReleaseState, string)`, Brain Monkey `Functions\when()`/`expect()`, Stub `\ET_Core_PageResource::$removed`. - Produces: - `class ThemeOptionsAdapter implements DiviAdapter` – `manifest()`: Bereich `Manifest::DESIGN`; Aufrufe `et_sanitize_font_choices` (1), `et_sanitize_global_font_weight` (1), `et_sanitize_float_number` (1), `et_sanitize_int_number` (1), `et_divi_global_font_style` (1), `et_update_option` (2), `et_delete_option` (1), `ET_Core_PageResource::remove_static_resources` (2), `et_pb_force_regenerate_templates` (0), `et_pb_is_allowed` (1); Schlüssel `et_divi`; Mirror `customizer-typography` (`functions.php`, `et_divi_customizer_theme_settings`, `class: null`, Fähigkeiten `[ThemeOptionRead, ThemeOptionWrite]`); Vorgabe `[ThemeOptionWrite]`. `public const SANITIZERS` (siehe oben). `read(): array` – die vorhandenen Whitelist-Schlüssel aus `get_option('et_divi')` in `SANITIZERS`-Reihenfolge, Werte anderen Typs (Array, `null`, `true`) fehlen. `sanitize(string $key, mixed $value): mixed` – Divis Sanitizer des Schlüssels, Nicht-Skalare → `false` ohne Divi-Aufruf, unbekannter Schlüssel → `InvalidArgumentException('not a theme option of the whitelist: ')`. `write(array $set, array $unset): array` – `ReleaseGate::assertFeature(ThemeOptionWrite)`, fremde Schlüssel → `InvalidArgumentException` (nichts geschrieben), dann `et_update_option` je `$set`-Schlüssel, `et_delete_option` je `$unset`-Schlüssel, `\ET_Core_PageResource::remove_static_resources('all', 'all')`, `et_pb_force_regenerate_templates()`, Zurücklesen über `read()`; weicht ein Wert ab (`!==`) oder ist ein `$unset`-Schlüssel noch da → `RuntimeException('theme options not written: ')`; sonst liefert es `read()`. `canCustomize(): bool` = `function_exists('et_pb_is_allowed') && et_pb_is_allowed('theme_customizer')`. - `class FontCatalog implements DiviAdapter` – `manifest()`: Aufrufe `et_builder_get_fonts` (0), `et_builder_get_custom_fonts` (0), `et_builder_get_google_fonts` (0), `et_builder_get_websafe_fonts` (0) mit der Vorgabe `[ThemeOptionWrite, FontUpload, VariableWrite]` (W1), `et_pb_is_allowed` (1) nur `[FontUpload]`; Schlüssel `et_uploaded_fonts`, `et_google_fonts_cache`, `et_google_api_settings` (Dokumentation: die Optionen hinter Divis Listen, gelesen nur über Divis Funktionen). `names(): list` – Schlüssel von `et_builder_get_custom_fonts()` dann `et_builder_get_fonts()` (genau Divis Sanitizer-Liste), je Instanz einmal geladen; `uploaded(): list` und `reserved(): list` (Google, Web-Safe, hochgeladen) je Aufruf frisch; `has(string $name): bool` exakt (`in_array(…, true)`); `canManage(): bool` = `et_pb_is_allowed('custom_fonts_management')`, ohne Divi `false`. Nicht-Array-Listen (kaputter Filter) zählen als leer; numerische Familiennamen bleiben Strings. - `DesignRights::themeOptions(DesignSystemWriter $writer, ThemeOptionsAdapter $options): ?WP_Error` – `edit_theme_options`, dann `canCustomize()`; `$writer` wird nicht gefragt (siehe `## ABWEICHUNGEN`, B2). Meldungen: „Theme-Optionen ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).“ / „Die Divi-Rolle dieses Kontos erlaubt die Theme-Anpassung nicht; ohne sie ändert LIT MCP keine Theme-Optionen.“ (Hinweis Rollen-Editor). - `DesignRights::fonts(FontCatalog $fonts): ?WP_Error` – `upload_files` („Schriften hochladen erfordert das Recht, Dateien hochzuladen (upload_files).“), `edit_theme_options` („Schriften hochladen erfordert das Recht, …“), `canManage()` („Die Divi-Rolle dieses Kontos erlaubt „Eigene Schriften verwalten“ nicht; ohne das Recht lädt LIT MCP keine Schriften hoch.“, Hinweis Rollen-Editor). - `presets()`, `colors()`, `variables()` verhalten sich unverändert (gleiche Texte; `designer()` nutzt jetzt `themeRight()`). - [ ] **Step 1: Failing Test `ThemeOptionsAdapterTest` schreiben** Datei `tests/Unit/Integrations/Divi/Design/ThemeOptionsAdapterTest.php` anlegen: ```php area); self::assertSame( [ 'et_sanitize_font_choices()', 'et_sanitize_global_font_weight()', 'et_sanitize_float_number()', 'et_sanitize_int_number()', 'et_divi_global_font_style()', 'et_update_option()', 'et_delete_option()', 'ET_Core_PageResource::remove_static_resources()', 'et_pb_force_regenerate_templates()', 'et_pb_is_allowed()', ], array_map(static fn (Call $call): string => $call->label(), $manifest->calls) ); self::assertSame( [1, 1, 1, 1, 1, 2, 1, 2, 0, 1], array_map(static fn (Call $call): ?int => $call->args, $manifest->calls) ); self::assertSame(['et_divi'], $manifest->keys); self::assertSame([DiviFeature::ThemeOptionWrite], $manifest->features); self::assertCount(1, $manifest->mirrors); $mirror = $manifest->mirrors[0]; self::assertSame('customizer-typography', $mirror->id); self::assertSame('functions.php', $mirror->file); self::assertSame('et_divi_customizer_theme_settings', $mirror->function); self::assertNull($mirror->class); self::assertSame( [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $manifest->featuresOf($mirror) ); } public function testTheSanitizerTableCoversTheWhitelistWithDivisCallsOfTheManifest(): void { self::assertSame(self::KEYS, array_keys(ThemeOptionsAdapter::SANITIZERS)); $divi = array_values(array_unique(array_diff(ThemeOptionsAdapter::SANITIZERS, ['absint']))); sort($divi); self::assertSame( [ 'et_divi_global_font_style', 'et_sanitize_float_number', 'et_sanitize_font_choices', 'et_sanitize_global_font_weight', 'et_sanitize_int_number', ], $divi ); $calls = array_map( static fn (Call $call): string => $call->symbol . '/' . $call->args, ThemeOptionsAdapter::manifest()->calls ); foreach ($divi as $sanitizer) { self::assertContains($sanitizer . '/1', $calls, 'every Divi sanitizer is a call of the manifest'); } } public function testSanitizeRefusesAKeyOutsideTheWhitelist(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('not a theme option of the whitelist: accent_color'); (new ThemeOptionsAdapter())->sanitize('accent_color', '#ff0000'); } public function testSizesGoThroughWordPressAbsintAndNonScalarsAreRefused(): void { $adapter = new ThemeOptionsAdapter(); self::assertSame(15, $adapter->sanitize('body_font_size', '15.7'), 'absint rounds, the caller compares'); self::assertSame(1200, $adapter->sanitize('content_width', 1200)); self::assertFalse($adapter->sanitize('row_padding', ['2'])); } /** @runInSeparateProcess */ public function testSanitizeCallsDivisCustomizerSanitizerOfTheKey(): void { Functions\expect('et_sanitize_font_choices')->once()->with('Roboto')->andReturn('Roboto'); Functions\expect('et_sanitize_global_font_weight')->once()->with('600')->andReturn('600'); Functions\expect('et_sanitize_float_number')->once()->with('1.8')->andReturn(1.8); Functions\expect('et_sanitize_int_number')->once()->with('-1')->andReturn(-1); Functions\expect('et_divi_global_font_style')->once()->with('italic|uppercase')->andReturn(false); $adapter = new ThemeOptionsAdapter(); self::assertSame('Roboto', $adapter->sanitize('secondary_nav_font', 'Roboto')); self::assertSame('600', $adapter->sanitize('body_font_weight', '600')); self::assertSame(1.8, $adapter->sanitize('body_header_height', '1.8')); self::assertSame(-1, $adapter->sanitize('body_header_spacing', '-1')); self::assertFalse($adapter->sanitize('body_header_style', 'italic|uppercase'), 'Divi refuses combinations'); } public function testReadReturnsThePresentWhitelistKeysWithTheirStoredTypes(): void { Functions\when('get_option')->alias(static fn (string $name): mixed => $name === 'et_divi' ? [ 'row_padding' => null, 'accent_color' => '#ff0000', 'body_font' => 'Roboto', 'body_font_height' => 1.8, 'gutter_width' => ['3'], 'heading_font' => false, 'body_font_size' => 15, ] : false); self::assertSame( ['heading_font' => false, 'body_font' => 'Roboto', 'body_font_size' => 15, 'body_font_height' => 1.8], (new ThemeOptionsAdapter())->read(), 'whitelist only, in W2 order; values of another type are not theme option values' ); } public function testReadWithoutThemeOptionsIsEmpty(): void { Functions\when('get_option')->justReturn(false); self::assertSame([], (new ThemeOptionsAdapter())->read()); } public function testWritesNeedAReleasedDivi(): void { ReleaseGate::useResolver( static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0') ); $this->expectException(DiviLocked::class); (new ThemeOptionsAdapter())->write(['body_font_size' => 16], []); } public function testWriteRefusesAKeyOutsideTheWhitelistBeforeWriting(): void { // Unit tests have no et_update_option(): reaching it would be an Error, not this exception. $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('not a theme option of the whitelist: accent_color, divi_logo'); (new ThemeOptionsAdapter())->write(['body_font_size' => 16, 'accent_color' => '#f00'], ['divi_logo']); } /** @runInSeparateProcess */ public function testWriteUpdatesAndDeletesPerKeyClearsTheCachesAndReadsBack(): void { $stored = ['body_font_size' => 14, 'heading_font' => 'Roboto', 'accent_color' => '#ff0000']; $log = []; Functions\when('get_option')->alias(static function (string $name) use (&$stored): mixed { return $name === 'et_divi' ? $stored : false; }); Functions\when('et_update_option')->alias(static function (string $key, mixed $value) use ( &$stored, &$log ): void { $stored[$key] = $value; $log[] = 'update ' . $key; }); Functions\when('et_delete_option')->alias(static function (string $key) use (&$stored, &$log): void { unset($stored[$key]); $log[] = 'delete ' . $key; }); Functions\when('et_pb_force_regenerate_templates')->alias(static function () use (&$log): void { $log[] = 'regenerate templates after ' . count(\ET_Core_PageResource::$removed) . ' static CSS clear'; }); \ET_Core_PageResource::$removed = []; $result = (new ThemeOptionsAdapter())->write( ['body_font_size' => 16, 'body_font_height' => 1.8], ['heading_font'] ); self::assertSame(['body_font_size' => 16, 'body_font_height' => 1.8], $result); self::assertSame( [ 'update body_font_size', 'update body_font_height', 'delete heading_font', 'regenerate templates after 1 static CSS clear', ], $log ); self::assertSame([['all', 'all', false]], \ET_Core_PageResource::$removed, 'like a customizer save'); self::assertSame('#ff0000', $stored['accent_color'], 'other theme options stay'); } /** @runInSeparateProcess */ public function testWriteThrowsWhenTheReadBackDiffers(): void { $stored = ['body_font_size' => 14, 'heading_font' => 'Roboto']; Functions\when('get_option')->alias(static fn (string $name): mixed => $name === 'et_divi' ? $stored : false); // Divi silently saves nothing (e.g. a filter or another process). Functions\when('et_update_option')->justReturn(null); Functions\when('et_delete_option')->justReturn(null); Functions\when('et_pb_force_regenerate_templates')->justReturn(null); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('theme options not written: body_font_size, heading_font'); (new ThemeOptionsAdapter())->write(['body_font_size' => 16], ['heading_font']); } public function testWithoutDiviNobodyCustomizesTheTheme(): void { // Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed". self::assertFalse((new ThemeOptionsAdapter())->canCustomize()); } /** @runInSeparateProcess */ public function testCanCustomizeAsksTheThemeCustomizerRight(): void { Functions\when('et_pb_is_allowed')->alias(static fn (string $cap): bool => $cap === 'theme_customizer'); self::assertTrue((new ThemeOptionsAdapter())->canCustomize()); } } ``` - [ ] **Step 2: Failing Test `FontCatalogTest` schreiben** Datei `tests/Unit/Integrations/Divi/Design/FontCatalogTest.php` anlegen: ```php area); self::assertSame( [ 'et_builder_get_fonts()', 'et_builder_get_custom_fonts()', 'et_builder_get_google_fonts()', 'et_builder_get_websafe_fonts()', 'et_pb_is_allowed()', ], array_map(static fn (Call $call): string => $call->label(), $manifest->calls) ); self::assertSame([0, 0, 0, 0, 1], array_map(static fn (Call $call): ?int => $call->args, $manifest->calls)); self::assertSame(['et_uploaded_fonts', 'et_google_fonts_cache', 'et_google_api_settings'], $manifest->keys); // Plan 4d W1: a broken font list locks every tool that checks font names against it. self::assertSame( [DiviFeature::ThemeOptionWrite, DiviFeature::FontUpload, DiviFeature::VariableWrite], $manifest->features ); self::assertSame([DiviFeature::FontUpload], $manifest->featuresOf($manifest->calls[4])); } /** @runInSeparateProcess */ public function testNamesAreTheExactFamiliesOfDivisSanitizerLoadedOnce(): void { Functions\expect('et_builder_get_custom_fonts')->once()->andReturn(['Hausschrift' => [], '1942' => []]); Functions\expect('et_builder_get_fonts')->once() ->andReturn(['Arial' => [], 'Open Sans' => [], 'Roboto' => []]); $catalog = new FontCatalog(); self::assertSame(['Hausschrift', '1942', 'Arial', 'Open Sans', 'Roboto'], $catalog->names()); self::assertTrue($catalog->has('Roboto')); self::assertTrue($catalog->has('1942'), 'a numeric family name stays a string'); self::assertFalse($catalog->has('roboto'), 'exact, like Divi'); self::assertFalse($catalog->has('none'), 'Divi stores the default font as false, never as a name'); self::assertSame($catalog->names(), $catalog->names()); } /** @runInSeparateProcess */ public function testUploadedAndReservedAreReadFreshEachTime(): void { Functions\expect('et_builder_get_custom_fonts')->twice() ->andReturn(['Hausschrift' => []], ['Hausschrift' => [], 'Neu' => []]); Functions\when('et_builder_get_google_fonts')->justReturn(['Roboto' => []]); Functions\when('et_builder_get_websafe_fonts')->justReturn(['Arial' => [], 'Roboto' => []]); $catalog = new FontCatalog(); self::assertSame(['Hausschrift'], $catalog->uploaded()); self::assertSame(['Roboto', 'Arial', 'Hausschrift', 'Neu'], $catalog->reserved(), 'after an upload'); } /** @runInSeparateProcess */ public function testABrokenListCountsAsEmpty(): void { // A filter of et_builder_google_fonts that returns no array (Divi passes it on unchecked). Functions\when('et_builder_get_google_fonts')->justReturn(null); Functions\when('et_builder_get_websafe_fonts')->justReturn(['Arial' => []]); Functions\when('et_builder_get_custom_fonts')->justReturn([]); self::assertSame(['Arial'], (new FontCatalog())->reserved()); } public function testWithoutDiviNobodyManagesFonts(): void { // Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed". self::assertFalse((new FontCatalog())->canManage()); } /** @runInSeparateProcess */ public function testCanManageAsksTheCustomFontsRight(): void { Functions\when('et_pb_is_allowed')->alias(static fn (string $cap): bool => $cap === 'custom_fonts_management'); self::assertTrue((new FontCatalog())->canManage()); } } ``` - [ ] **Step 3: Gepinnte Tabelle in `DiviFeatureAssignmentTest` erweitern** ```diff diff --git a/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php b/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php index 5033b0f..1b32ee5 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php @@ -6,8 +6,10 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; +use LIT\MCP\Integrations\Divi\Design\FontCatalog; use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter; use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; +use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Pages\DiviPages; @@ -82,6 +84,26 @@ final class DiviFeatureAssignmentTest extends TestCase 'GlobalData::set_global_variables()' => ['variable-write'], 'et_pb_is_allowed()' => ['variable-write'], ], + ThemeOptionsAdapter::class => [ + 'et_sanitize_font_choices()' => ['theme-option-write'], + 'et_sanitize_global_font_weight()' => ['theme-option-write'], + 'et_sanitize_float_number()' => ['theme-option-write'], + 'et_sanitize_int_number()' => ['theme-option-write'], + 'et_divi_global_font_style()' => ['theme-option-write'], + 'et_update_option()' => ['theme-option-write'], + 'et_delete_option()' => ['theme-option-write'], + 'ET_Core_PageResource::remove_static_resources()' => ['theme-option-write'], + 'et_pb_force_regenerate_templates()' => ['theme-option-write'], + 'et_pb_is_allowed()' => ['theme-option-write'], + 'Nachbau et_divi_customizer_theme_settings()' => ['theme-option-read', 'theme-option-write'], + ], + FontCatalog::class => [ + 'et_builder_get_fonts()' => ['font-upload', 'theme-option-write', 'variable-write'], + 'et_builder_get_custom_fonts()' => ['font-upload', 'theme-option-write', 'variable-write'], + 'et_builder_get_google_fonts()' => ['font-upload', 'theme-option-write', 'variable-write'], + 'et_builder_get_websafe_fonts()' => ['font-upload', 'theme-option-write', 'variable-write'], + 'et_pb_is_allowed()' => ['font-upload'], + ], DiviModules::class => [ 'ModuleRegistration::get_all_core_modules_metadata()' => ['catalog'], 'ModuleRegistration::get_default_attrs()' => ['catalog'], @@ -143,7 +165,7 @@ final class DiviFeatureAssignmentTest extends TestCase } self::assertSame(self::EXPECTED, $actual); - self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all ten adapters, in report order'); + self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all twelve adapters, in report order'); } public function testReadingAdaptersCarryNoWriteFeature(): void ``` - [ ] **Step 4: RED** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'ThemeOptionsAdapterTest|FontCatalogTest|DiviFeatureAssignmentTest'` Expected: FAIL – `Tests: 24, Assertions: 25, Errors: 15, Failures: 6`: fünfzehn Mal `Error: Class "LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter" not found` bzw. `…FontCatalog" not found`, vier Mal `Failed asserting that exception of type "Error" matches expected exception …` (gleiche Ursache), dazu `DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement` (aus Task 1) und `::testTheAssignmentIsThePinnedTable`. - [ ] **Step 5: `ThemeOptionsAdapter` implementieren** Datei `src/Integrations/Divi/Design/ThemeOptionsAdapter.php` anlegen: ```php the sanitize_callback of their setting in * et_divi_customizer_theme_settings() (mirror customizer-typography). sanitize() calls it by this name; every * Divi name here is a call of the manifest (ThemeOptionsAdapterTest), absint is WordPress core. */ public const SANITIZERS = [ 'heading_font' => 'et_sanitize_font_choices', 'body_font' => 'et_sanitize_font_choices', 'primary_nav_font' => 'et_sanitize_font_choices', 'secondary_nav_font' => 'et_sanitize_font_choices', 'heading_font_weight' => 'et_sanitize_global_font_weight', 'body_font_weight' => 'et_sanitize_global_font_weight', 'body_font_size' => 'absint', 'tablet_body_font_size' => 'absint', 'phone_body_font_size' => 'absint', 'body_header_size' => 'absint', 'tablet_header_font_size' => 'absint', 'phone_header_font_size' => 'absint', 'body_font_height' => 'et_sanitize_float_number', 'body_header_height' => 'et_sanitize_float_number', 'body_header_spacing' => 'et_sanitize_int_number', 'body_header_style' => 'et_divi_global_font_style', 'content_width' => 'absint', 'gutter_width' => 'absint', 'section_padding' => 'absint', 'row_padding' => 'absint', ]; public static function manifest(): Manifest { return new Manifest( Manifest::DESIGN, calls: [ new Call('et_sanitize_font_choices', 1), new Call('et_sanitize_global_font_weight', 1), new Call('et_sanitize_float_number', 1), new Call('et_sanitize_int_number', 1), new Call('et_divi_global_font_style', 1), new Call('et_update_option', 2), new Call('et_delete_option', 1), new Call('ET_Core_PageResource::remove_static_resources', 2), new Call('et_pb_force_regenerate_templates', 0), new Call('et_pb_is_allowed', 1), ], keys: [self::OPTIONS], mirrors: [ // shortcut: the fingerprint covers the whole customizer function (about 4,000 lines) and changes with // every customizer edit; switch to a narrower excerpt when it fires too often. new Mirror( 'customizer-typography', 'functions.php', 'et_divi_customizer_theme_settings', null, 'Standards, Bereiche und Sanitizer der 20 Theme-Optionen (ThemeOptionSpec)', [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite] ), ], features: [DiviFeature::ThemeOptionWrite] ); } /** * The whitelist keys present in et_divi with their raw value, in the order of SANITIZERS; a missing key (Divi's * default) is missing here too. A value of another type (array, null, true) is no customizer value and left out. * * @return array */ public function read(): array { $options = get_option(self::OPTIONS); if (! is_array($options)) { return []; } $stored = []; foreach (array_keys(self::SANITIZERS) as $key) { $value = $options[$key] ?? null; if (is_int($value) || is_float($value) || is_string($value) || $value === false) { $stored[$key] = $value; } } return $stored; } /** * Divi's customizer sanitizer of $key applied to $value; false = Divi refuses it (Divi itself would store false). * Non-scalars never reach Divi's sanitizers. * * @throws InvalidArgumentException When $key is not a whitelist key. */ public function sanitize(string $key, mixed $value): mixed { $sanitizer = self::SANITIZERS[$key] ?? throw new InvalidArgumentException( 'not a theme option of the whitelist: ' . $key ); return is_scalar($value) ? $sanitizer($value) : false; } /** * Writes like a customizer save: $set per key with et_update_option(), $unset (back to Divi's default) with * et_delete_option(), then the static CSS and the builder caches; reads back from et_divi. * * @param array $set Checked values (ThemeOptionSpec, sanitize()) or raw values * of a snapshot. * @param list $unset * @return array read() after writing. * @throws DiviLocked When theme-option-write is not released – before anything is written. * @throws InvalidArgumentException When a key is not a whitelist key – before anything is written. * @throws RuntimeException When the read-back differs from what was written. */ public function write(array $set, array $unset): array { ReleaseGate::assertFeature(DiviFeature::ThemeOptionWrite); $unknown = array_diff([...array_keys($set), ...$unset], array_keys(self::SANITIZERS)); if ($unknown !== []) { throw new InvalidArgumentException('not a theme option of the whitelist: ' . implode(', ', $unknown)); } foreach ($set as $key => $value) { et_update_option($key, $value); } foreach ($unset as $key) { et_delete_option($key); } // What Divi runs on customize_save_after (research A.2): the customizer CSS lives in the static resources. \ET_Core_PageResource::remove_static_resources('all', 'all'); et_pb_force_regenerate_templates(); $stored = $this->read(); $wrong = []; foreach ($set as $key => $value) { if (! array_key_exists($key, $stored) || $stored[$key] !== $value) { $wrong[] = $key; } } foreach ($unset as $key) { if (array_key_exists($key, $stored)) { $wrong[] = $key; } } if ($wrong !== []) { throw new RuntimeException('theme options not written: ' . implode(', ', $wrong)); } return $stored; } /** Divi's role right "Theme-Anpassung" (theme_customizer); fail closed without Divi. */ public function canCustomize(): bool { return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed('theme_customizer'); } } ``` - [ ] **Step 6: `FontCatalog` implementieren** Datei `src/Integrations/Divi/Design/FontCatalog.php` anlegen (die Hilfsfunktion heißt `namesOf`, nicht `families…`: PHPCompatibility meldet Funktionsnamen mit dem Präfix `fam` als entfernte PHP-Erweiterung `fam`): ```php |null */ private ?array $names = null; public static function manifest(): Manifest { return new Manifest( Manifest::DESIGN, calls: [ new Call('et_builder_get_fonts', 0), new Call('et_builder_get_custom_fonts', 0), new Call('et_builder_get_google_fonts', 0), new Call('et_builder_get_websafe_fonts', 0), new Call('et_pb_is_allowed', 1, [DiviFeature::FontUpload]), ], // The options behind Divi's lists; read only through the functions above. keys: ['et_uploaded_fonts', 'et_google_fonts_cache', 'et_google_api_settings'], // Plan 4d W1: font variables are checked against the same list, so a break locks all three on purpose. features: [DiviFeature::ThemeOptionWrite, DiviFeature::FontUpload, DiviFeature::VariableWrite] ); } /** @return list Exact family names Divi's sanitizer accepts: uploaded, then web-safe and Google. */ public function names(): array { return $this->names ??= self::namesOf(et_builder_get_custom_fonts(), et_builder_get_fonts()); } /** @return list */ public function uploaded(): array { return self::namesOf(et_builder_get_custom_fonts()); } /** @return list Google, web-safe and uploaded names – an upload must not reuse one (W9). */ public function reserved(): array { return self::namesOf( et_builder_get_google_fonts(), et_builder_get_websafe_fonts(), et_builder_get_custom_fonts() ); } public function has(string $name): bool { return in_array($name, $this->names(), true); } /** Divi's role right "Eigene Schriften verwalten" (custom_fonts_management); fail closed without Divi. */ public function canManage(): bool { return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed('custom_fonts_management'); } /** * @param mixed ...$lists Divi's lists (family => settings); anything else (a broken filter) is skipped. * @return list */ private static function namesOf(mixed ...$lists): array { $names = []; foreach ($lists as $list) { if (! is_array($list)) { continue; } foreach (array_keys($list) as $name) { $names[] = (string) $name; } } return array_values(array_unique($names)); } } ``` - [ ] **Step 7: Adapter in `DiviAdapters::CLASSES` aufnehmen** ```diff diff --git a/src/Integrations/Divi/Release/DiviAdapters.php b/src/Integrations/Divi/Release/DiviAdapters.php index 5e09753..293e76f 100644 --- a/src/Integrations/Divi/Release/DiviAdapters.php +++ b/src/Integrations/Divi/Release/DiviAdapters.php @@ -6,8 +6,10 @@ namespace LIT\MCP\Integrations\Divi\Release; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; +use LIT\MCP\Integrations\Divi\Design\FontCatalog; use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter; use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; +use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Pages\DiviPages; @@ -31,6 +33,8 @@ final class DiviAdapters DesignSystemWriter::class, GlobalColorsAdapter::class, GlobalVariablesAdapter::class, + ThemeOptionsAdapter::class, + FontCatalog::class, DiviModules::class, DynamicContentMap::class, ]; ``` - [ ] **Step 8: GREEN (ohne Fingerabdrücke)** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'ThemeOptionsAdapterTest|FontCatalogTest|DiviFeatureAssignmentTest|DiviBoundaryTest|DiviFeatureTest'` Expected: `OK (31 tests, 171 assertions)` (u. a. `DiviBoundaryTest::testAdaptersDeclareExactlyTheDiviNamesTheyUse`: die Adapter nutzen genau die Namen ihres Manifests – die Sanitizer-Strings der Tabelle, `et_divi`, die Funktionsaufrufe, `ET_Core_PageResource`; `'theme_customizer'`, `'custom_fonts_management'`, `'functions.php'` und `'absint'` sind für `DiviTouches` keine Divi-Namen). Run: `composer test` Expected: `Tests: 3140, …, Failures: 5` – vier in `DiviCheckCommandTest` (`testUpdateReferenceWritesNothingWhenASourceIsUnwritable`, `…WhenASignatureIsUnreadable`, `…WritesFingerprintsSignaturesAndSources`, `…KeepsItsOwnExitCodeAndStillPrintsTheMatrix`: `…/Divi/functions.php is not readable` – der Temp-Theme-Ordner hat noch keinen Stand-in für den neuen Nachbau, Step 9) und `DiviReferenceTest::testEveryMirrorAndEveryCallHasAnExpectedFingerprintAndEveryMirrorAReferenceSource` (in `fingerprints.json` fehlen `customizer-typography` und zwölf `sig:`-Schlüssel, Step 12). - [ ] **Step 9: `DiviCheckCommandTest` um den siebten Nachbau ergänzen** ```diff diff --git a/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php b/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php index 3098480..6c914bd 100644 --- a/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php +++ b/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php @@ -228,14 +228,14 @@ final class DiviCheckCommandTest extends TestCase self::assertSame(0, $code); self::assertSame( [ - 'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed', 'tb-api-save', - 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags', + 'customizer-typography', 'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed', + 'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags', ], array_keys(DiviReference::expected($this->expectedFile)) ); self::assertSame(str_repeat('b', 64), DiviReference::expected($this->expectedFile)['sig:A::b']); self::assertContains( - 'Referenz aktualisiert (6 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.', + 'Referenz aktualisiert (7 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.', $this->lines ); self::assertStringStartsWith( @@ -244,10 +244,11 @@ final class DiviCheckCommandTest extends TestCase ); } - /** Minimal stand-ins for the six mirrored Divi functions at their real paths. */ + /** Minimal stand-ins for the seven mirrored Divi functions at their real paths. */ private function writeDivi(): void { $files = [ + 'functions.php' => "function et_divi_customizer_theme_settings( \$wp_customize ) {\n}", 'includes/builder/shortcode-core.php' => "function et_pb_submit_layout( \$args ) {\n\treturn 1;\n}", 'includes/builder/frontend-builder/theme-builder/api.php' => "function et_theme_builder_api_save() {\n}", 'includes/builder/frontend-builder/theme-builder/theme-builder.php' => ``` Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviCheckCommandTest` Expected: `OK (17 tests, 50 assertions)` (vorher `Tests: 17, Assertions: 42, Failures: 4`). - [ ] **Step 10: Failing Tests für `DesignRights::themeOptions()`/`fonts()` schreiben** ```diff diff --git a/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php b/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php index 0714c59..7857e7b 100644 --- a/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php +++ b/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php @@ -7,7 +7,9 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; use Brain\Monkey\Functions; use LIT\MCP\Integrations\Divi\Design\DesignRights; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; +use LIT\MCP\Integrations\Divi\Design\FontCatalog; use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; +use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter; use LIT\MCP\Tests\Unit\TestCase; use Mockery; @@ -73,4 +75,46 @@ final class DesignRightsTest extends TestCase $refusal = self::assertToolError(DesignRights::variables($writer, $variables), 'forbidden'); self::assertStringContainsString('Variablen ändern erfordert', $refusal['message']); } + + public function testThemeOptionsNeedEditThemeOptionsAndTheThemeCustomizerRight(): void + { + $writer = Mockery::mock(DesignSystemWriter::class); + // Divi's customizer does not need the Visual Builder (plan 4d, rights). + $writer->expects('canUseVisualBuilder')->never(); + $options = Mockery::mock(ThemeOptionsAdapter::class); + $options->allows('canCustomize')->andReturn(true, false); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options'); + + self::assertNull(DesignRights::themeOptions($writer, $options)); + $refusal = self::assertToolError(DesignRights::themeOptions($writer, $options), 'forbidden'); + self::assertStringContainsString('Theme-Anpassung', $refusal['message']); + self::assertStringContainsString('keine Theme-Optionen', $refusal['message']); + self::assertStringContainsString('Rollen-Editor', $refusal['hint']); + + Functions\when('current_user_can')->justReturn(false); + $refusal = self::assertToolError(DesignRights::themeOptions($writer, $options), 'forbidden'); + self::assertStringContainsString('Theme-Optionen ändern erfordert', $refusal['message']); + self::assertStringContainsString('edit_theme_options', $refusal['message']); + } + + public function testFontsNeedUploadFilesEditThemeOptionsAndTheFontsRight(): void + { + $fonts = Mockery::mock(FontCatalog::class); + $fonts->allows('canManage')->andReturn(true, false); + Functions\when('current_user_can')->justReturn(true); + + self::assertNull(DesignRights::fonts($fonts)); + $refusal = self::assertToolError(DesignRights::fonts($fonts), 'forbidden'); + self::assertStringContainsString('Eigene Schriften verwalten', $refusal['message']); + self::assertStringContainsString('Rollen-Editor', $refusal['hint']); + + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options'); + $refusal = self::assertToolError(DesignRights::fonts($fonts), 'forbidden'); + self::assertStringContainsString('upload_files', $refusal['message']); + + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'upload_files'); + $refusal = self::assertToolError(DesignRights::fonts($fonts), 'forbidden'); + self::assertStringContainsString('Schriften hochladen erfordert', $refusal['message']); + self::assertStringContainsString('edit_theme_options', $refusal['message']); + } } ``` Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DesignRightsTest` Expected: FAIL – `Tests: 6, Assertions: 33, Errors: 2`: `Error: Call to undefined method LIT\MCP\Integrations\Divi\Design\DesignRights::themeOptions()` und `…::fonts()`. - [ ] **Step 11: `DesignRights` erweitern** `src/Integrations/Divi/Design/DesignRights.php` vollständig ersetzen (`unfilteredHtml()`, `markup()`, `presets()`, `colors()`, `variables()` mit unveränderten Texten; die drei gleichen Rollen-Editor-Hinweise stehen jetzt in `ROLE_EDITOR`, die `edit_theme_options`-Prüfung in `themeRight()`): ```php current_user_can('unfiltered_html'); } /** * Refuses the write when the user lacks unfiltered_html and $markupIds (PresetBook::markupOutside()) is not empty. * * @param Closure(): bool $unfiltered * @param list $markupIds */ public static function markup( Closure $unfiltered, array $markupIds, string $message = self::OTHER_MARKUP ): ?WP_Error { if ($markupIds === [] || $unfiltered()) { return null; } return Errors::forbidden( $message, 'Einen Administrator (mit dem Recht unfiltered_html) die Änderung vornehmen lassen.' ); } public static function presets(DesignSystemWriter $writer): ?WP_Error { return self::designer($writer, 'Presets'); } /** Colors: the same rights as presets (spec §10 – stricter than Divi, which asks edit_theme_options only). */ public static function colors(DesignSystemWriter $writer): ?WP_Error { return self::designer($writer, 'Farben'); } /** Variables: also Divi's role right "Variables Manager" – without it Divi silently saves nothing (plan 4c V7). */ public static function variables(DesignSystemWriter $writer, GlobalVariablesAdapter $variables): ?WP_Error { $refusal = self::designer($writer, 'Variablen'); if ($refusal !== null || $variables->canManage()) { return $refusal; } return Errors::forbidden( 'Die Divi-Rolle dieses Kontos erlaubt den Variablen-Manager nicht; ohne ihn speichert Divi keine ' . 'Variablen.', self::ROLE_EDITOR ); } /** * Theme options (writing and restoring): edit_theme_options and Divi's role right "Theme-Anpassung". $writer is * not asked – Divi's customizer needs no Visual Builder; it keeps the signature of the other design rights. */ public static function themeOptions(DesignSystemWriter $writer, ThemeOptionsAdapter $options): ?WP_Error { $refusal = self::themeRight('Theme-Optionen ändern'); if ($refusal !== null || $options->canCustomize()) { return $refusal; } return Errors::forbidden( 'Die Divi-Rolle dieses Kontos erlaubt die Theme-Anpassung nicht; ohne sie ändert LIT MCP keine ' . 'Theme-Optionen.', self::ROLE_EDITOR ); } /** Font uploads: upload_files, edit_theme_options and Divi's role right "Eigene Schriften verwalten". */ public static function fonts(FontCatalog $fonts): ?WP_Error { if (! current_user_can('upload_files')) { return Errors::forbidden('Schriften hochladen erfordert das Recht, Dateien hochzuladen (upload_files).'); } $refusal = self::themeRight('Schriften hochladen'); if ($refusal !== null || $fonts->canManage()) { return $refusal; } return Errors::forbidden( 'Die Divi-Rolle dieses Kontos erlaubt „Eigene Schriften verwalten“ nicht; ohne das Recht lädt LIT MCP ' . 'keine Schriften hoch.', self::ROLE_EDITOR ); } /** edit_theme_options and Divi's role right "Visual Builder verwenden"; $what names the area in the message. */ private static function designer(DesignSystemWriter $writer, string $what): ?WP_Error { $refusal = self::themeRight($what . ' ändern'); if ($refusal !== null) { return $refusal; } if (! $writer->canUseVisualBuilder()) { return Errors::forbidden( sprintf( 'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine %s.', $what ), self::ROLE_EDITOR ); } return null; } /** edit_theme_options; $activity starts the message ("Presets ändern", "Schriften hochladen"). */ private static function themeRight(string $activity): ?WP_Error { if (current_user_can('edit_theme_options')) { return null; } return Errors::forbidden(sprintf( '%s erfordert das Recht, das Design der Website anzupassen (edit_theme_options).', $activity )); } } ``` Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DesignRightsTest` Expected: `OK (6 tests, 61 assertions)`. Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DesignRightsTest|DiviPresets|DiviDesign|DiviColors|DiviVariables'` Expected: `OK (144 tests, 1233 assertions)` (die Preset-, Farb- und Variablen-Tools sehen dieselben Meldungen wie vorher). - [ ] **Step 12: Signatur-Referenzen und Nachbau-Fingerabdruck in DDEV erzeugen** `wp lit-mcp divi-check --update-reference` (`src/Integrations/Divi/Cli/DiviCheckCommand.php::updateReference()`) verlangt einen Administrator, schreibt nur, wenn außer „Fingerabdrücke nachgebauter Divi-Funktionen“ alle Prüfschritte grün sind (Manifeste: die zwölf neuen Aufrufe existieren mit passender Argumentzahl – geprüft über `getNumberOfRequiredParameters() <= args <= getNumberOfParameters()`; die fehlende Signatur-Referenz und der neue Nachbau sind nur `reference-missing`-Probleme), und nur in einer Entwicklungskopie mit `tests/` (`DiviReference::sourceDir()`). Es schreibt zuerst die Referenzquellen aller Nachbauten (die sechs alten unverändert, neu `customizer-typography.txt`), zuletzt `fingerprints.json` (Nachbauten + `sig:` jedes Aufrufs aus `DiviAdapters::calls()`). Run: ```bash cd /home/janniclas/Projekte/WordPress ddev start ddev exec wp plugin list --status=active --field=name ddev exec bash -c 'wp --user="$(wp user list --role=administrator --field=user_login | head -1)" lit-mcp divi-check --update-reference' ``` Expected: die Prüfschritte mit `✓` (`Fingerabdrücke nachgebauter Divi-Funktionen` darf `✗` sein), dann `Referenz aktualisiert (7 Fingerabdrücke, 44 Signaturen). Smoke-Skripte laufen lassen, dann committen.` und die Fähigkeiten-Matrix; Exit-Code 0. Bricht WP-CLI mit Monolog-/Deprecation-Ausgaben ab, vorher `ddev exec wp plugin deactivate postlos asynclistry` und danach wieder `ddev exec wp plugin activate postlos asynclistry` (nur, wenn die Liste oben sie als aktiv zeigte). Ergebnis prüfen: ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git diff -U0 src/Integrations/Divi/Release/fingerprints.json | grep '^[-+] ' git status --short tests/Fixtures/divi/reference jq -r 'keys[]' src/Integrations/Divi/Release/fingerprints.json | grep -c '^sig:' jq -r '."customizer-typography"' src/Integrations/Divi/Release/fingerprints.json ``` Expected: genau dreizehn `+`-Zeilen und keine `-`-Zeile – `"customizer-typography": "0264fa5a2ba604542e1e1ad0c0339f04c8f4f67297087a0f92954ee63d343fd0"` und die `sig:`-Schlüssel `et_builder_get_custom_fonts`, `et_builder_get_fonts`, `et_builder_get_google_fonts`, `et_builder_get_websafe_fonts`, `et_delete_option`, `et_divi_global_font_style`, `et_pb_force_regenerate_templates`, `et_sanitize_float_number`, `et_sanitize_font_choices`, `et_sanitize_global_font_weight`, `et_sanitize_int_number`, `et_update_option` (je `"sig:": ""`); `?? tests/Fixtures/divi/reference/customizer-typography.txt` als einzige Änderung unter `reference` (beginnt mit `function et_divi_customizer_theme_settings( $wp_customize ) {`, ~104 KB); `44`; der Hash oben (in der Kopie mit `SourceFingerprint` über das installierte `functions.php` nachgerechnet). Ändert sich ein anderer Schlüssel oder weicht der Hash ab, **nicht committen**, sondern prüfen (installiertes Divi ≠ 5.15.1 oder eine Divi-Signatur hat sich geändert). - [ ] **Step 13: Gesamtlauf, Lint, Freigabe-Smoke** Run: `composer test && composer lint` Expected: `OK (3142 tests, …)` (in der Kopie mit nachgestellten Referenzen: `OK (3142 tests, 19539 assertions)`), Lint sauber. Run: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh` Expected: `ALL CHECKS PASSED` – u. a. `ok - manifests and fingerprints match the installed Divi` (keine `reference-missing`-Probleme für die neuen Aufrufe und den Nachbau) und `ok - lit-site-status: sixteen features released (code), each with its tools`. Läuft nur mit deaktivierten Plugins `postlos` und `asynclistry` (vorher `ddev exec wp plugin list` notieren, danach den Zustand wiederherstellen). - [ ] **Step 14: Commit (Tasks 1 und 2)** ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git add src/Integrations/Divi/Release/DiviFeature.php tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php \ tests/Integration/divi-release-smoke.sh \ src/Integrations/Divi/Design/ThemeOptionsAdapter.php src/Integrations/Divi/Design/FontCatalog.php \ tests/Unit/Integrations/Divi/Design/ThemeOptionsAdapterTest.php tests/Unit/Integrations/Divi/Design/FontCatalogTest.php \ src/Integrations/Divi/Release/DiviAdapters.php tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php \ tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php \ src/Integrations/Divi/Release/fingerprints.json tests/Fixtures/divi/reference/customizer-typography.txt \ src/Integrations/Divi/Design/DesignRights.php tests/Unit/Integrations/Divi/Design/DesignRightsTest.php git commit -m "feat: Fähigkeiten theme-option-read/-write und font-upload; ThemeOptionsAdapter und FontCatalog (Manifest, Nachbau customizer-typography, Signatur-Referenzen) und DesignRights::themeOptions()/fonts()" -m "Co-Authored-By: Claude " ``` --- ### Task 3: Reine Klassen `ThemeOptionSpec`, `ThemeOptionBook`, `ThemeOptionDiff`, `FontSuggest` Der reine Bestand der Theme-Optionen (W2, W3, W4, W6, W5): die Tabelle der 20 Customizer-Einstellungen mit Prüfung, Normalisierung und Vererbung (`ThemeOptionSpec`), der unveränderliche Bestand mit `rev()` (`ThemeOptionBook`), der Diff mit Zusammenfassung (`ThemeOptionDiff`) und die Namensvorschläge für unbekannte Schriften (`FontSuggest`). Kein WordPress, kein Divi: die Klassen brauchen weder Adapter noch Manifest und sind unabhängig von Task 1 und 2 (Task 4 und 5 setzen sie voraus). Vier Teilblöcke, je eine Klasse und ein Commit; 3c baut auf 3b auf (`ThemeOptionDiffTest` nutzt `ThemeOptionBookTest::stored()`), 3b auf 3a. Die Werte der Tabelle sind gegen Divis `et_divi_customizer_theme_settings()` (`functions.php` 1903–2561) nachgeprüft: Standards, `min`/`max`/`step` und Sanitizer stimmen mit Tabelle A der Recherche überein (z. B. `body_header_spacing` −2…10, `body_font_height` 0,8…3 in 0,1-Schritten, `content_width` 960…1920). **Files:** - Create: `src/Integrations/Divi/Design/ThemeOptionSpec.php`, `src/Integrations/Divi/Design/ThemeOptionBook.php`, `src/Integrations/Divi/Design/ThemeOptionDiff.php`, `src/Integrations/Divi/Design/FontSuggest.php` - Create: `tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php`, `tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php`, `tests/Unit/Integrations/Divi/Design/ThemeOptionDiffTest.php`, `tests/Unit/Integrations/Divi/Design/FontSuggestTest.php` **Interfaces:** - Consumes: `LIT\MCP\Abilities\Rev::of()` (vorhanden). - Produces (Namen und Signaturen wie im Schnittstellenvertrag; Verfeinerungen siehe `## ABWEICHUNGEN`): - `ThemeOptionSpec::KEYS` (20 Schlüssel, Reihenfolge W2), `definition(string): array`, `isKey(string): bool`, `check(string, mixed): ?string`, `normalize(string, mixed): int|float|string`, `effective(string, array): int|float|string|null`. `definition()` und `check()`/`normalize()`/`effective()` werfen `InvalidArgumentException` bei einem Schlüssel außerhalb der Whitelist. - `ThemeOptionBook::fromDivi(array)`, `data()`, `rev()`, `has()`, `get()`, `with()`, `without()`; `with()` wirft `InvalidArgumentException` bei unbekanntem Schlüssel. - `ThemeOptionDiff::between(ThemeOptionBook, ThemeOptionBook)`, `summary(array)`. - `FontSuggest::similar(string, list, int = 5)`. - Ausgangslage vor dem Task: `composer test` = 3121 Tests grün (Stand 2307027). **Einordnung der Prüfregeln (für Task 4 und 5):** `check()` meldet je Schlüssel genau eine deutsche Meldung mit dem Label als Präfix (`„Fließtext-Schriftgröße: …“`), Task 4 setzt davor den Feldpfad. `null` ist „zurücksetzen“ und wird vom Aufrufer vor `check()` behandelt; `check(…, null)` ist trotzdem definiert und lehnt ab. Schriftnamen: nur Form (1–100 Zeichen, keine Steuerzeichen, kein `<>"'` und Backtick, nicht `none`); ob die Schrift existiert, prüft `FontCatalog` (Task 4) mit `FontSuggest`. Zahlen kommen als Zahl oder als Ziffern-String (`"15"`, `"1.7"`, `"-1"`), Exponent, Einheit und Leerzeichen sind keine Zahl. #### Task 3a: `ThemeOptionSpec` Die Tabelle steht als Literal in der Klasse (`DEFINITIONS`), die geteilten Bereiche als Konstanten (`BODY_SIZE`, `HEADER_SIZE`, `LINE_HEIGHT`, `WEIGHT`, `FONT`), damit keine Zeile über 120 Zeichen läuft. Der Mirror `customizer-typography` (Task 2) hasht Divis Funktion; wer die Tabelle ändert, ändert sie nur nach einem Divi-Update und mit neuem Fingerabdruck. - [ ] **Step 3a.1: Failing Test `ThemeOptionSpecTest` schreiben** Datei `tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php` anlegen: ```php */ public static function definitions(): array { return [ 'heading_font' => ['heading_font', 'font', null, null, null], 'body_font' => ['body_font', 'font', null, null, null], 'primary_nav_font' => ['primary_nav_font', 'font', null, null, null], 'secondary_nav_font' => ['secondary_nav_font', 'font', null, null, null], 'heading_font_weight' => ['heading_font_weight', 'weight', '500', null, null], 'body_font_weight' => ['body_font_weight', 'weight', '500', null, null], 'body_font_size' => ['body_font_size', 'int', 14, 10, 32], 'tablet_body_font_size' => ['tablet_body_font_size', 'int', 14, 10, 32], 'phone_body_font_size' => ['phone_body_font_size', 'int', 14, 10, 32], 'body_header_size' => ['body_header_size', 'int', 30, 22, 72], 'tablet_header_font_size' => ['tablet_header_font_size', 'int', 30, 22, 72], 'phone_header_font_size' => ['phone_header_font_size', 'int', 30, 22, 72], 'body_font_height' => ['body_font_height', 'float', 1.7, 0.8, 3], 'body_header_height' => ['body_header_height', 'float', 1.0, 0.8, 3], 'body_header_spacing' => ['body_header_spacing', 'int', 0, -2, 10], 'body_header_style' => ['body_header_style', 'style', null, null, null], 'content_width' => ['content_width', 'int', 1080, 960, 1920], 'gutter_width' => ['gutter_width', 'int', 3, 1, 4], 'section_padding' => ['section_padding', 'int', 4, 0, 10], 'row_padding' => ['row_padding', 'int', 2, 0, 10], ]; } /** @dataProvider definitions */ public function testEveryKeyHasItsDefinition( string $key, string $type, mixed $default, mixed $min, mixed $max ): void { $definition = ThemeOptionSpec::definition($key); self::assertTrue(ThemeOptionSpec::isKey($key)); self::assertSame($type, $definition['type']); self::assertSame($default, $definition['default']); self::assertNotSame('', $definition['label']); self::assertSame($min, $definition['min'] ?? null); self::assertSame($max, $definition['max'] ?? null); self::assertSame(in_array($type, ['int', 'float'], true), isset($definition['step'])); self::assertSame(in_array($type, ['weight', 'style'], true), isset($definition['choices'])); } public function testInheritanceAndChoicesAreDefined(): void { self::assertSame('body_font_size', ThemeOptionSpec::definition('tablet_body_font_size')['inherits']); self::assertSame('tablet_body_font_size', ThemeOptionSpec::definition('phone_body_font_size')['inherits']); self::assertSame('body_header_size', ThemeOptionSpec::definition('tablet_header_font_size')['inherits']); self::assertSame('tablet_header_font_size', ThemeOptionSpec::definition('phone_header_font_size')['inherits']); self::assertArrayNotHasKey('inherits', ThemeOptionSpec::definition('body_font_size')); self::assertSame( ['100', '200', '300', '400', '500', '600', '700', '800', '900'], ThemeOptionSpec::definition('body_font_weight')['choices'] ); self::assertSame( ['italic', 'uppercase', 'underline'], ThemeOptionSpec::definition('body_header_style')['choices'] ); self::assertSame(0.1, ThemeOptionSpec::definition('body_font_height')['step']); } public function testAnUnknownKeyIsNoKeyAndHasNoDefinition(): void { self::assertFalse(ThemeOptionSpec::isKey('accent_color')); $this->expectException(InvalidArgumentException::class); ThemeOptionSpec::definition('accent_color'); } /** @return array */ public static function validValues(): array { return [ 'int lower bound' => ['body_font_size', 10], 'int upper bound' => ['body_font_size', 32], 'int as digits' => ['body_font_size', '15'], 'int as whole float' => ['body_font_size', 15.0], 'negative int bound' => ['body_header_spacing', -2], 'negative digits' => ['body_header_spacing', '-1'], 'width bounds' => ['content_width', 1920], 'zero padding' => ['section_padding', 0], 'float lower bound' => ['body_font_height', 0.8], 'float upper bound as int' => ['body_font_height', 3], 'float on a step' => ['body_font_height', 1.7], 'float as digits' => ['body_header_height', '1.2'], 'float from int' => ['body_header_height', 1], 'font' => ['heading_font', 'Open Sans'], 'font with umlaut and padding' => ['body_font', " Fjalla Önö \t"], 'font of 100 characters' => ['primary_nav_font', str_repeat('a', 100)], 'weight as text' => ['heading_font_weight', '700'], 'weight as int' => ['body_font_weight', 400], 'style' => ['body_header_style', 'uppercase'], ]; } /** @dataProvider validValues */ public function testCheckAcceptsValidValues(string $key, mixed $value): void { self::assertNull(ThemeOptionSpec::check($key, $value)); } /** @return array */ public static function invalidValues(): array { return [ 'int below' => ['body_font_size', 9, 'außerhalb von 10 bis 32'], 'int above' => ['body_font_size', 33, 'außerhalb von 10 bis 32'], 'int digits above' => ['content_width', '1921', 'außerhalb von 960 bis 1920'], 'negative below' => ['body_header_spacing', -3, 'außerhalb von -2 bis 10'], 'float with a fraction for an int key' => ['body_font_size', 15.5, 'ganze Zahl'], 'digits with a fraction for an int key' => ['row_padding', '2.5', 'ganze Zahl'], 'non-numeric text' => ['body_font_size', 'groß', 'erwartet eine Zahl, erhalten "groß"'], 'unit suffix' => ['body_font_size', '15px', 'erwartet eine Zahl'], 'exponent' => ['body_font_size', '1e1', 'erwartet eine Zahl'], 'spaces around digits' => ['body_font_size', ' 15', 'erwartet eine Zahl'], 'empty text' => ['gutter_width', '', 'erwartet eine Zahl'], 'true' => ['body_font_size', true, 'erhalten true'], 'false' => ['body_font_size', false, 'erhalten false'], 'null' => ['body_font_size', null, 'erhalten null'], 'array' => ['body_font_size', [14], 'erhalten einen array'], 'infinity' => ['body_font_height', INF, 'erwartet eine Zahl'], 'not a number' => ['body_font_height', NAN, 'erwartet eine Zahl'], 'float below' => ['body_font_height', 0.7, 'außerhalb von 0,8 bis 3'], 'float above' => ['body_header_height', 3.1, 'außerhalb von 0,8 bis 3'], 'float off the step' => ['body_font_height', 1.75, 'Schrittweite 0,1 (ab 0,8)'], 'float digits off the step' => ['body_header_height', '1.05', 'Schrittweite'], 'font as number' => ['heading_font', 12, 'erwartet einen Schriftnamen'], 'font as false' => ['heading_font', false, 'erwartet einen Schriftnamen'], 'font as null' => ['heading_font', null, 'erhalten null'], 'empty font' => ['body_font', '', '1 bis 100 Zeichen'], 'blank font' => ['body_font', " \t ", '1 bis 100 Zeichen'], 'font of 101 characters' => ['body_font', str_repeat('a', 101), '1 bis 100 Zeichen'], 'font with a control character' => ['body_font', "Open\nSans", 'unzulässige Zeichen'], 'font with angle brackets' => ['body_font', 'Open', 'unzulässige Zeichen'], 'font with a quote' => ['primary_nav_font', 'Open "Sans"', 'unzulässige Zeichen'], 'font with an apostrophe' => ['secondary_nav_font', "Open'Sans", 'unzulässige Zeichen'], 'font with invalid utf-8' => ['body_font', "Open\xC3(Sans", 'unzulässige Zeichen'], 'font none' => ['heading_font', 'None', 'null setzt auf Divis Standardschrift zurück'], 'weight off the list' => ['body_font_weight', '550', 'erlaubt ist genau ein Wert aus 100, 200'], 'weight as float' => ['body_font_weight', 500.0, 'erhalten 500'], 'weight word' => ['heading_font_weight', 'bold', 'erhalten "bold"'], 'weight as null' => ['heading_font_weight', null, 'erhalten null'], 'style combination' => ['body_header_style', 'italic|uppercase', 'genau ein Wert'], 'style empty' => ['body_header_style', '', 'genau ein Wert'], 'style bold' => ['body_header_style', 'bold', 'genau ein Wert'], 'style case' => ['body_header_style', 'Italic', 'genau ein Wert'], 'style as list' => ['body_header_style', ['italic'], 'erhalten einen array'], ]; } /** @dataProvider invalidValues */ public function testCheckRejectsInvalidValuesWithAGermanMessage(string $key, mixed $value, string $needle): void { $message = ThemeOptionSpec::check($key, $value); self::assertIsString($message); self::assertStringContainsString($needle, $message); self::assertStringStartsWith(ThemeOptionSpec::definition($key)['label'] . ':', $message); } public function testCheckOfAnUnknownKeyThrows(): void { $this->expectException(InvalidArgumentException::class); ThemeOptionSpec::check('logo', 'x'); } public function testNormalizeCastsToTheTypeOfTheKey(): void { self::assertSame(15, ThemeOptionSpec::normalize('body_font_size', '15')); self::assertSame(15, ThemeOptionSpec::normalize('body_font_size', 15.0)); self::assertSame(-1, ThemeOptionSpec::normalize('body_header_spacing', '-1')); self::assertSame(1.7, ThemeOptionSpec::normalize('body_font_height', '1.7')); self::assertSame(1.0, ThemeOptionSpec::normalize('body_header_height', 1)); self::assertSame('700', ThemeOptionSpec::normalize('heading_font_weight', 700)); self::assertSame('400', ThemeOptionSpec::normalize('body_font_weight', '400')); self::assertSame('Open Sans', ThemeOptionSpec::normalize('heading_font', " Open Sans\t")); self::assertSame('italic', ThemeOptionSpec::normalize('body_header_style', 'italic')); } public function testEffectiveFallsBackToTheDivisDefaultsWhenNothingIsStored(): void { self::assertSame(14, ThemeOptionSpec::effective('body_font_size', [])); self::assertSame(1.7, ThemeOptionSpec::effective('body_font_height', [])); self::assertSame('500', ThemeOptionSpec::effective('heading_font_weight', [])); self::assertSame(1080, ThemeOptionSpec::effective('content_width', [])); self::assertNull(ThemeOptionSpec::effective('heading_font', [])); self::assertNull(ThemeOptionSpec::effective('body_header_style', [])); self::assertSame(14, ThemeOptionSpec::effective('phone_body_font_size', [])); self::assertSame(30, ThemeOptionSpec::effective('phone_header_font_size', [])); } public function testEffectiveInheritsTheNextLargerSizeWhileUnset(): void { $stored = ['body_font_size' => 18, 'body_header_size' => 40]; self::assertSame(18, ThemeOptionSpec::effective('tablet_body_font_size', $stored)); self::assertSame(18, ThemeOptionSpec::effective('phone_body_font_size', $stored)); self::assertSame(40, ThemeOptionSpec::effective('tablet_header_font_size', $stored)); self::assertSame(40, ThemeOptionSpec::effective('phone_header_font_size', $stored)); } public function testAStoredOverrideBreaksTheChainAtItsLevelOnly(): void { $stored = ['body_font_size' => 18, 'tablet_body_font_size' => 16, 'tablet_header_font_size' => 28]; self::assertSame(18, ThemeOptionSpec::effective('body_font_size', $stored)); self::assertSame(16, ThemeOptionSpec::effective('tablet_body_font_size', $stored)); self::assertSame(16, ThemeOptionSpec::effective('phone_body_font_size', $stored)); self::assertSame(28, ThemeOptionSpec::effective('phone_header_font_size', $stored)); self::assertSame(30, ThemeOptionSpec::effective('body_header_size', $stored)); $stored['phone_body_font_size'] = '12'; self::assertSame(12, ThemeOptionSpec::effective('phone_body_font_size', $stored)); } public function testEffectiveTreatsFalseAndGarbageAsUnsetAndCastsStoredDigits(): void { self::assertNull(ThemeOptionSpec::effective('heading_font', ['heading_font' => false])); self::assertNull(ThemeOptionSpec::effective('heading_font', ['heading_font' => 'none'])); self::assertSame('Lato', ThemeOptionSpec::effective('heading_font', ['heading_font' => 'Lato'])); self::assertSame(14, ThemeOptionSpec::effective('body_font_size', ['body_font_size' => 'groß'])); self::assertSame(15, ThemeOptionSpec::effective('body_font_size', ['body_font_size' => '15'])); self::assertSame(1.2, ThemeOptionSpec::effective('body_font_height', ['body_font_height' => '1.2'])); self::assertSame('300', ThemeOptionSpec::effective('body_font_weight', ['body_font_weight' => 300])); self::assertSame(40, ThemeOptionSpec::effective('body_font_size', ['body_font_size' => 40]), 'no range check'); self::assertSame(18, ThemeOptionSpec::effective('tablet_body_font_size', [ 'body_font_size' => 18, 'tablet_body_font_size' => false, ])); } public function testEffectiveOfAnUnknownKeyThrows(): void { $this->expectException(InvalidArgumentException::class); ThemeOptionSpec::effective('nope', []); } } ``` - [ ] **Step 3a.2: RED** Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php` Expected: FAIL – `Tests: 91, Assertions: 2, Errors: 89, Failures: 2` (alle 91 Fälle rot; die zwei Failures sind die `expectException`-Tests, die statt der erwarteten Ausnahme einen `Error` bekommen); jeder Fehler ist `Error: Class "LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec" not found`. - [ ] **Step 3a.3: `ThemeOptionSpec` implementieren** Datei `src/Integrations/Divi/Design/ThemeOptionSpec.php` anlegen: ```php 'int', 'default' => 14, 'min' => 10, 'max' => 32, 'step' => 1]; private const HEADER_SIZE = ['type' => 'int', 'default' => 30, 'min' => 22, 'max' => 72, 'step' => 1]; private const LINE_HEIGHT = ['type' => 'float', 'min' => 0.8, 'max' => 3, 'step' => 0.1]; private const WEIGHT = ['type' => 'weight', 'default' => '500', 'choices' => self::WEIGHTS]; private const FONT = ['type' => 'font', 'default' => null]; /** * Tablet/phone sizes inherit the next larger one while unset (ruling W3); their default is what Divi's CSS * readers assume (14 / 30) when nothing is stored at all. */ private const DEFINITIONS = [ 'heading_font' => ['label' => 'Überschrift-Schrift', ...self::FONT], 'body_font' => ['label' => 'Fließtext-Schrift', ...self::FONT], 'primary_nav_font' => ['label' => 'Hauptmenü-Schrift', ...self::FONT], 'secondary_nav_font' => ['label' => 'Nebenmenü-Schrift', ...self::FONT], 'heading_font_weight' => ['label' => 'Überschrift-Schriftstärke', ...self::WEIGHT], 'body_font_weight' => ['label' => 'Fließtext-Schriftstärke', ...self::WEIGHT], 'body_font_size' => ['label' => 'Fließtext-Schriftgröße', ...self::BODY_SIZE], 'tablet_body_font_size' => [ 'label' => 'Fließtext-Schriftgröße (Tablet)', 'inherits' => 'body_font_size', ...self::BODY_SIZE, ], 'phone_body_font_size' => [ 'label' => 'Fließtext-Schriftgröße (Smartphone)', 'inherits' => 'tablet_body_font_size', ...self::BODY_SIZE, ], 'body_header_size' => ['label' => 'Überschrift-Schriftgröße', ...self::HEADER_SIZE], 'tablet_header_font_size' => [ 'label' => 'Überschrift-Schriftgröße (Tablet)', 'inherits' => 'body_header_size', ...self::HEADER_SIZE, ], 'phone_header_font_size' => [ 'label' => 'Überschrift-Schriftgröße (Smartphone)', 'inherits' => 'tablet_header_font_size', ...self::HEADER_SIZE, ], 'body_font_height' => ['label' => 'Fließtext-Zeilenhöhe', 'default' => 1.7, ...self::LINE_HEIGHT], 'body_header_height' => ['label' => 'Überschrift-Zeilenhöhe', 'default' => 1.0, ...self::LINE_HEIGHT], 'body_header_spacing' => [ 'type' => 'int', 'label' => 'Überschrift-Buchstabenabstand', 'default' => 0, 'min' => -2, 'max' => 10, 'step' => 1, ], 'body_header_style' => [ 'type' => 'style', 'label' => 'Überschrift-Schriftstil', 'default' => null, 'choices' => self::STYLES, ], 'content_width' => [ 'type' => 'int', 'label' => 'Inhaltsbreite', 'default' => 1080, 'min' => 960, 'max' => 1920, 'step' => 1, ], 'gutter_width' => [ 'type' => 'int', 'label' => 'Spaltenabstand', 'default' => 3, 'min' => 1, 'max' => 4, 'step' => 1, ], 'section_padding' => [ 'type' => 'int', 'label' => 'Abschnitt-Innenabstand', 'default' => 4, 'min' => 0, 'max' => 10, 'step' => 1, ], 'row_padding' => [ 'type' => 'int', 'label' => 'Zeile-Innenabstand', 'default' => 2, 'min' => 0, 'max' => 10, 'step' => 1, ], ]; /** * @return array{type: 'int'|'float'|'font'|'weight'|'style', label: string, default: int|float|string|null, * min?: int|float, max?: int|float, step?: int|float, choices?: list, inherits?: string} * @throws InvalidArgumentException For a key outside the whitelist. */ public static function definition(string $key): array { return self::DEFINITIONS[$key] ?? throw new InvalidArgumentException( sprintf('Unbekannte Theme-Option "%s".', $key) ); } public static function isKey(string $key): bool { return isset(self::DEFINITIONS[$key]); } /** * Type, range, step and choices of $value; null = valid, else a German message. null as a value is "unset" – * the caller handles it before check() – and counts as invalid here. "none" and false are no font (ruling W3). */ public static function check(string $key, mixed $value): ?string { $definition = self::definition($key); $label = $definition['label']; return match ($definition['type']) { 'int', 'float' => self::checkNumber($label, $definition, $value), 'font' => self::checkFont($label, $value), default => self::checkChoice($label, $definition['choices'] ?? [], $value, $definition['type']), }; } /** * The value in the type Divi stores: int, float, weight as string, font and style as trimmed string. * Only meaningful for a value check() accepted. */ public static function normalize(string $key, mixed $value): int|float|string { return match (self::definition($key)['type']) { 'int' => (int) self::number($value), 'float' => (float) self::number($value), 'weight' => (string) (int) self::number($value), default => trim((string) (is_scalar($value) ? $value : '')), }; } /** * What Divi uses for $key: the stored value, else (while unset) the effective value of the key it inherits from * (ruling W3), else Divi's default; null when there is neither (a font or style left at Divi's default). * A stored value that is false, null or no usable value counts as unset. * * @param array $stored */ public static function effective(string $key, array $stored): int|float|string|null { $definition = self::definition($key); $value = self::usable($definition['type'], $stored[$key] ?? null); if ($value !== null) { return $value; } return isset($definition['inherits']) ? self::effective($definition['inherits'], $stored) : $definition['default']; } /** @param array $definition */ private static function checkNumber(string $label, array $definition, mixed $value): ?string { $number = self::number($value); if ($number === null || ! is_finite((float) $number)) { return sprintf('%s: erwartet eine Zahl, erhalten %s.', $label, self::describe($value)); } if ($definition['type'] === 'int' && floor((float) $number) !== (float) $number) { return sprintf('%s: erwartet eine ganze Zahl, erhalten %s.', $label, self::describe($value)); } $min = $definition['min']; $max = $definition['max']; if ($number < $min || $number > $max) { return sprintf( '%s: %s liegt außerhalb von %s bis %s.', $label, self::decimal($number), self::decimal($min), self::decimal($max) ); } $steps = ($number - $min) / $definition['step']; if (abs($steps - round($steps)) > 1e-6) { return sprintf( '%s: %s passt nicht zur Schrittweite %s (ab %s).', $label, self::decimal($number), self::decimal($definition['step']), self::decimal($min) ); } return null; } private static function checkFont(string $label, mixed $value): ?string { if (! is_string($value)) { return sprintf('%s: erwartet einen Schriftnamen (Text), erhalten %s.', $label, self::describe($value)); } $name = trim($value); if ($name === '' || mb_strlen($name) > self::MAX_FONT_LENGTH) { return sprintf('%s: der Schriftname muss 1 bis %d Zeichen lang sein.', $label, self::MAX_FONT_LENGTH); } if (preg_match('/[\x00-\x1f\x7f<>"\'`]/u', $name) === 1 || ! mb_check_encoding($name, 'UTF-8')) { return sprintf('%s: der Schriftname enthält unzulässige Zeichen.', $label); } if (strtolower($name) === 'none') { return sprintf('%s: "none" ist kein Schriftname; null setzt auf Divis Standardschrift zurück.', $label); } return null; } /** @param list $choices */ private static function checkChoice(string $label, array $choices, mixed $value, string $type): ?string { $text = match (true) { is_string($value) => $value, is_int($value) && $type === 'weight' => (string) $value, default => null, }; if ($text === null || ! in_array($text, $choices, true)) { return sprintf( '%s: erlaubt ist genau ein Wert aus %s, erhalten %s.', $label, implode(', ', $choices), self::describe($value) ); } return null; } /** The number in $value (int, float or a plain decimal string), else null. */ private static function number(mixed $value): int|float|null { return match (true) { is_int($value), is_float($value) => $value, is_string($value) && preg_match('/^-?\d+(\.\d+)?$/', $value) === 1 => $value + 0, default => null, }; } /** A stored value in the type of $type, or null when it is not usable (false, empty, garbage). */ private static function usable(string $type, mixed $value): int|float|string|null { if ($type === 'font' || $type === 'style') { return is_string($value) && trim($value) !== '' && strtolower(trim($value)) !== 'none' ? trim($value) : null; } $number = self::number($value); if ($number === null) { return null; } return match ($type) { 'int' => (int) $number, 'float' => (float) $number, default => (string) (int) $number, }; } private static function decimal(int|float $number): string { return str_replace('.', ',', (string) $number); } private static function describe(mixed $value): string { return match (true) { is_string($value) => '"' . mb_substr($value, 0, 40) . '"', is_bool($value) => $value ? 'true' : 'false', $value === null => 'null', is_int($value), is_float($value) => var_export($value, true), default => 'einen ' . get_debug_type($value), }; } } ``` - [ ] **Step 3a.4: GREEN und Lint** Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php && composer lint` Expected: `OK (91 tests, 354 assertions)`; phpcs ohne Meldung. - [ ] **Step 3a.5: Commit** ```bash git add src/Integrations/Divi/Design/ThemeOptionSpec.php tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php git commit -m "feat: ThemeOptionSpec – die 20 Theme-Optionen mit Prüfung, Normalisierung und Vererbung (W2–W4)" -m "Co-Authored-By: Claude " ``` #### Task 3b: `ThemeOptionBook` `fromDivi()` nimmt ausdrücklich auch das ganze `et_divi`-Array (Defensive: nur Whitelist-Schlüssel, feste Reihenfolge, `null` und Werte anderer Typen als `int|float|string|false` fallen weg). `rev()` hasht kanonisches JSON mit `JSON_PRESERVE_ZERO_FRACTION`, damit `1` und `1.0` verschieden sind – genau wie `"1"` und `1` (der Diff zählt Typwechsel). - [ ] **Step 3b.1: Failing Test `ThemeOptionBookTest` schreiben** Datei `tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php` anlegen: ```php The shape of get_option('et_divi') with the options of other areas around. */ public static function stored(): array { return [ 'accent_color' => '#2ea3f2', 'body_font_size' => 16, 'heading_font' => false, 'logo' => 'https://example.test/logo.png', 'body_font_height' => 1.7, 'body_header_style' => 'uppercase', 'body_font' => 'Lato', ]; } public function testOnlyWhitelistKeysStayInTheFixedOrderWithTheirRawTypes(): void { $book = ThemeOptionBook::fromDivi(self::stored()); self::assertSame([ 'heading_font' => false, 'body_font' => 'Lato', 'body_font_size' => 16, 'body_font_height' => 1.7, 'body_header_style' => 'uppercase', ], $book->data()); self::assertSame(['heading_font', 'body_font', 'body_font_size', 'body_font_height', 'body_header_style'], array_keys($book->data())); } public function testNullAndValuesOfOtherTypesAreDropped(): void { $book = ThemeOptionBook::fromDivi([ 'heading_font' => null, 'body_font' => ['Lato'], 'content_width' => true, 'row_padding' => 0, 'gutter_width' => '3', 'section_padding' => 0.0, 'body_header_spacing' => new \stdClass(), ]); self::assertSame(['gutter_width' => '3', 'section_padding' => 0.0, 'row_padding' => 0], $book->data()); self::assertSame([], ThemeOptionBook::fromDivi([])->data()); } public function testHasAndGetDistinguishAFalseValueFromAMissingKey(): void { $book = ThemeOptionBook::fromDivi(self::stored()); self::assertTrue($book->has('heading_font')); self::assertFalse($book->get('heading_font')); self::assertFalse($book->has('primary_nav_font')); self::assertNull($book->get('primary_nav_font')); self::assertFalse($book->has('logo')); self::assertNull($book->get('logo')); self::assertSame(16, $book->get('body_font_size')); } public function testRevIgnoresTheOrderAndOtherKeysButNotValuesOrTheirTypes(): void { $book = ThemeOptionBook::fromDivi(self::stored()); $shuffled = ThemeOptionBook::fromDivi(array_reverse(self::stored(), true) + ['phone_body_font_size' => null]); self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', $book->rev()); self::assertSame($book->rev(), $shuffled->rev()); self::assertSame($book->rev(), ThemeOptionBook::fromDivi($book->data())->rev()); self::assertNotSame($book->rev(), $book->with('body_font_size', 17)->rev()); self::assertNotSame($book->rev(), $book->with('body_font_size', '16')->rev(), '16 versus "16"'); self::assertNotSame($book->rev(), $book->with('heading_font', 'Lato')->rev(), 'false versus a font'); self::assertNotSame($book->rev(), $book->without('body_font_size')->rev()); self::assertNotSame( ThemeOptionBook::fromDivi(['body_header_height' => 1])->rev(), ThemeOptionBook::fromDivi(['body_header_height' => 1.0])->rev(), '1 versus 1.0' ); self::assertNotSame($book->rev(), ThemeOptionBook::fromDivi([])->rev()); } public function testWithAddsOrReplacesInPlaceOfTheFixedOrderAndLeavesTheOriginalAlone(): void { $book = ThemeOptionBook::fromDivi(self::stored()); $changed = $book->with('primary_nav_font', 'Roboto')->with('body_font_size', 18); self::assertSame(['heading_font', 'body_font', 'primary_nav_font', 'body_font_size', 'body_font_height', 'body_header_style'], array_keys($changed->data())); self::assertSame(18, $changed->get('body_font_size')); self::assertSame(16, $book->get('body_font_size')); self::assertFalse($book->has('primary_nav_font')); self::assertFalse($book->with('body_font', false)->get('body_font')); } public function testWithRejectsAKeyOutsideTheWhitelist(): void { $this->expectException(InvalidArgumentException::class); ThemeOptionBook::fromDivi([])->with('accent_color', '#fff'); } public function testWithoutRemovesAKeyAndIgnoresAMissingOne(): void { $book = ThemeOptionBook::fromDivi(self::stored()); $without = $book->without('heading_font'); self::assertFalse($without->has('heading_font')); self::assertTrue($book->has('heading_font')); self::assertSame($book->rev(), $book->without('row_padding')->rev()); self::assertSame($book->data(), $book->without('logo')->data()); } } ``` - [ ] **Step 3b.2: RED** Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php` Expected: FAIL – `Tests: 7, Assertions: 1, Errors: 6, Failures: 1`; jeder Fehler ist `Error: Class "LIT\MCP\Integrations\Divi\Design\ThemeOptionBook" not found`. - [ ] **Step 3b.3: `ThemeOptionBook` implementieren** Datei `src/Integrations/Divi/Design/ThemeOptionBook.php` anlegen: ```php $data */ private function __construct(private array $data) { } /** @param array $raw Other keys, null and values of other types are dropped. */ public static function fromDivi(array $raw): self { $data = []; foreach (ThemeOptionSpec::KEYS as $key) { $value = $raw[$key] ?? null; if ($value === false || is_int($value) || is_float($value) || is_string($value)) { $data[$key] = $value; } } return new self($data); } /** @return array */ public function data(): array { return $this->data; } /** Hash of the canonical JSON; the key order is fixed, and 1, 1.0 and "1" differ. */ public function rev(): string { // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4). $json = (string) json_encode($this->data, self::JSON_FLAGS); return Rev::of('theme_options' . "\0" . $json); } public function has(string $key): bool { return array_key_exists($key, $this->data); } public function get(string $key): int|float|string|false|null { return $this->data[$key] ?? null; } /** @throws InvalidArgumentException For a key outside the whitelist. */ public function with(string $key, int|float|string|false $value): self { ThemeOptionSpec::definition($key); return self::fromDivi([...$this->data, $key => $value]); } public function without(string $key): self { return self::fromDivi(array_diff_key($this->data, [$key => true])); } } ``` - [ ] **Step 3b.4: GREEN und Lint** Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php && composer lint` Expected: `OK (7 tests, 30 assertions)`; phpcs ohne Meldung. - [ ] **Step 3b.5: Commit** ```bash git add src/Integrations/Divi/Design/ThemeOptionBook.php tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php git commit -m "feat: ThemeOptionBook – unveränderlicher Bestand der Theme-Optionen mit rev() (W6)" -m "Co-Authored-By: Claude " ``` #### Task 3c: `ThemeOptionDiff` Aktionen `added` (Schlüssel vorher nicht gesetzt), `changed` (strikter Vergleich, also auch `false` ↔ Schriftname und `1` ↔ `"1"`), `reset` (Schlüssel danach nicht mehr gesetzt). `before`/`after` tragen die Rohwerte, `null` wo der Schlüssel fehlt. - [ ] **Step 3c.1: Failing Test `ThemeOptionDiffTest` schreiben** Datei `tests/Unit/Integrations/Divi/Design/ThemeOptionDiffTest.php` anlegen: ```php with('content_width', 1200) ->with('body_font_size', 18) ->with('body_font', 'Roboto') ->without('body_header_style'); self::assertSame([ ['key' => 'body_font', 'label' => 'Fließtext-Schrift', 'action' => 'changed', 'before' => 'Lato', 'after' => 'Roboto'], ['key' => 'body_font_size', 'label' => 'Fließtext-Schriftgröße', 'action' => 'changed', 'before' => 16, 'after' => 18], ['key' => 'body_header_style', 'label' => 'Überschrift-Schriftstil', 'action' => 'reset', 'before' => 'uppercase', 'after' => null], ['key' => 'content_width', 'label' => 'Inhaltsbreite', 'action' => 'added', 'before' => null, 'after' => 1200], ], ThemeOptionDiff::between($before, $after)); } public function testFalseIsAValueOfItsOwn(): void { $unset = ThemeOptionBook::fromDivi([]); $default = ThemeOptionBook::fromDivi(['heading_font' => false]); $font = ThemeOptionBook::fromDivi(['heading_font' => 'Lato']); self::assertSame( [['key' => 'heading_font', 'label' => 'Überschrift-Schrift', 'action' => 'added', 'before' => null, 'after' => false]], ThemeOptionDiff::between($unset, $default) ); self::assertSame( [['key' => 'heading_font', 'label' => 'Überschrift-Schrift', 'action' => 'changed', 'before' => false, 'after' => 'Lato']], ThemeOptionDiff::between($default, $font) ); self::assertSame( [['key' => 'heading_font', 'label' => 'Überschrift-Schrift', 'action' => 'reset', 'before' => false, 'after' => null]], ThemeOptionDiff::between($default, $unset) ); } public function testATypeChangeCountsAsAChange(): void { $int = ThemeOptionBook::fromDivi(['gutter_width' => 1, 'body_header_height' => 1]); $text = ThemeOptionBook::fromDivi(['gutter_width' => '1', 'body_header_height' => 1.0]); self::assertSame( [ ['key' => 'body_header_height', 'label' => 'Überschrift-Zeilenhöhe', 'action' => 'changed', 'before' => 1, 'after' => 1.0], ['key' => 'gutter_width', 'label' => 'Spaltenabstand', 'action' => 'changed', 'before' => 1, 'after' => '1'], ], ThemeOptionDiff::between($int, $text) ); } public function testSummaryCountsPerAction(): void { $before = ThemeOptionBook::fromDivi(ThemeOptionBookTest::stored()); $after = $before->with('content_width', 1200)->with('body_font_size', 18)->with('body_font', 'Roboto') ->without('body_header_style'); self::assertSame( 'Theme-Optionen: 1 gesetzt, 2 geändert, 1 zurückgesetzt.', ThemeOptionDiff::summary(ThemeOptionDiff::between($before, $after)) ); self::assertSame( 'Theme-Optionen: 1 gesetzt.', ThemeOptionDiff::summary(ThemeOptionDiff::between( ThemeOptionBook::fromDivi([]), ThemeOptionBook::fromDivi(['content_width' => 1200]) )) ); self::assertSame('Theme-Optionen: keine Änderung.', ThemeOptionDiff::summary([])); } } ``` - [ ] **Step 3c.2: RED** Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/ThemeOptionDiffTest.php` Expected: FAIL – `Tests: 5, Assertions: 0, Errors: 5`; jeder Fehler ist `Error: Class "LIT\MCP\Integrations\Divi\Design\ThemeOptionDiff" not found`. - [ ] **Step 3c.3: `ThemeOptionDiff` implementieren** Datei `src/Integrations/Divi/Design/ThemeOptionDiff.php` anlegen: ```php * action: added|changed|reset */ public static function between(ThemeOptionBook $before, ThemeOptionBook $after): array { $rows = []; foreach (ThemeOptionSpec::KEYS as $key) { $action = match (true) { ! $before->has($key) && $after->has($key) => 'added', $before->has($key) && ! $after->has($key) => 'reset', $before->get($key) !== $after->get($key) => 'changed', default => null, }; if ($action !== null) { $rows[] = [ 'key' => $key, 'label' => ThemeOptionSpec::definition($key)['label'], 'action' => $action, 'before' => $before->get($key), 'after' => $after->get($key), ]; } } return $rows; } /** * One German sentence for the dry run and the change log (spec §4 step 6). * * @param list> $diff */ public static function summary(array $diff): string { $count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff)); $parts = []; foreach (['added' => 'gesetzt', 'changed' => 'geändert', 'reset' => 'zurückgesetzt'] as $action => $word) { if (isset($count[$action])) { $parts[] = $count[$action] . ' ' . $word; } } return 'Theme-Optionen: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.'; } } ``` - [ ] **Step 3c.4: GREEN und Lint** Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/ThemeOptionDiffTest.php && composer lint` Expected: `OK (5 tests, 10 assertions)`; phpcs ohne Meldung. - [ ] **Step 3c.5: Commit** ```bash git add src/Integrations/Divi/Design/ThemeOptionDiff.php tests/Unit/Integrations/Divi/Design/ThemeOptionDiffTest.php git commit -m "feat: ThemeOptionDiff – Diff und Zusammenfassung der Theme-Optionen" -m "Co-Authored-By: Claude " ``` #### Task 3d: `FontSuggest` Reihenfolge der Vorschläge: gleicher Name ohne Beachtung der Schreibweise, Präfix, Teilstring, dann `levenshtein` ≤ `max(3, Länge/3)`; innerhalb eines Rangs der nähere, dann der kürzere, dann der alphabetisch erste Name – unabhängig von der Reihenfolge der Eingabeliste. - [ ] **Step 3d.1: Failing Test `FontSuggestTest` schreiben** Datei `tests/Unit/Integrations/Divi/Design/FontSuggestTest.php` anlegen: ```php $names * @return list At most $limit names. */ public static function similar(string $name, array $names, int $limit = 5): array { $needle = mb_strtolower(trim($name)); if ($needle === '' || $limit < 1) { return []; } $maxDistance = max(3, intdiv(mb_strlen($needle), 3)); $ranked = []; foreach (array_unique($names) as $candidate) { $lower = mb_strtolower($candidate); $distance = levenshtein($needle, $lower); $rank = match (true) { $lower === $needle => 0, str_starts_with($lower, $needle) => 1, str_contains($lower, $needle) => 2, $distance <= $maxDistance => 3, default => null, }; if ($rank !== null) { $ranked[] = [$rank, $distance, mb_strlen($candidate), $candidate]; } } usort($ranked, static fn (array $a, array $b): int => $a[0] <=> $b[0] ?: $a[1] <=> $b[1] ?: $a[2] <=> $b[2] ?: strcmp($a[3], $b[3])); return array_slice(array_column($ranked, 3), 0, $limit); } } ``` - [ ] **Step 3d.4: GREEN und Lint** Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/FontSuggestTest.php && composer lint` Expected: `OK (6 tests, 17 assertions)`; phpcs ohne Meldung. - [ ] **Step 3d.5: Commit** ```bash git add src/Integrations/Divi/Design/FontSuggest.php tests/Unit/Integrations/Divi/Design/FontSuggestTest.php git commit -m "feat: FontSuggest – ähnliche Schriftnamen für unbekannte Schriften (W5)" -m "Co-Authored-By: Claude " ``` #### Task 3e: Gesamtlauf - [ ] **Step 3e.1: Suite und Lint** Run: `composer test && composer lint` Expected: `OK (3230 tests, 19717 assertions)` (3121 + 109 neue: 91 + 7 + 5 + 6); `composer lint` ohne Meldung. Task 3 hat keinen weiteren Commit; `git status` ist sauber. ### Task 4: `ThemeOptionsArea`, `lit/divi-theme-options-get`, `lit/divi-theme-options-update` (W2–W7, W14) **Files:** - Create: `src/Integrations/Divi/Design/ThemeOptionsArea.php`, `DiviThemeOptionsGetAbility.php`, `FontCheck.php`, `DiviThemeOptionsUpdateAbility.php` - Test (neu): `tests/Unit/Integrations/Divi/Design/ThemeOptionsAreaTest.php`, `DiviThemeOptionsGetAbilityTest.php`, `FontCheckTest.php`, `DiviThemeOptionsUpdateAbilityTest.php` (alle über `LIT\MCP\Tests\Unit\TestCase`, weil sie Mockery-Fakes der Adapter nutzen) - Keine Verdrahtung in `DiviIntegration` (Task 9). **Interfaces:** - Consumes (Schnittstellenvertrag, Tasks 1–3): `DiviFeature::ThemeOptionRead`/`ThemeOptionWrite`; `ThemeOptionsAdapter::read()`, `::sanitize(string, mixed): mixed`, `::write(array $set, list $unset): array` (liefert das Zurückgelesene), `::canCustomize()`; `FontCatalog::names()`, `::has()`; `DesignRights::themeOptions(DesignSystemWriter, ThemeOptionsAdapter): ?WP_Error`; `ThemeOptionSpec::KEYS`, `::definition()`, `::isKey()`, `::check()`, `::normalize()`, `::effective()`; `ThemeOptionBook::fromDivi()`, `data()`, `rev()`, `has()`, `get()`, `with()`, `without()`; `ThemeOptionDiff::between()`, `::summary()`; `FontSuggest::similar()`. Bestehend: `DesignArea`, `DesignChange::run()/inputProperties()/outputSchema()`, `DesignPlan`, `ItemKeys::refuseUnknown()`, `Errors`, `Annotations`. - Produces: - `final class ThemeOptionsArea implements DesignArea` – `__construct(ThemeOptionsAdapter $options)`, Name `theme_options`, Label `Theme-Optionen`, Fähigkeit `ThemeOptionWrite`; zusätzlich `public const SITE_WIDE` (W7-Warnung, auch für Task 6) und `public static function same(mixed $a, mixed $b): bool` (gleich oder dieselbe Zahl in anderem Typ; auch vom Update genutzt). - `final class DiviThemeOptionsGetAbility implements Ability, RequiresDiviFeatures` – **`__construct(ThemeOptionsAdapter $options)`** (ohne `FontCatalog`, siehe ABWEICHUNGEN), `lit/divi-theme-options-get`, Fähigkeiten `[ThemeOptionRead]`. - `final class FontCheck` – `public static function problem(FontCatalog $fonts, string $name): ?string` (null = Divi kennt die Schrift exakt; sonst deutsche Meldung mit bis zu fünf Vorschlägen oder dem Hinweis auf `lit/divi-fonts-upload`). Auch von Task 5 genutzt. - `final class DiviThemeOptionsUpdateAbility implements Ability, RequiresDiviFeatures` – `__construct(DesignSystemWriter $writer, ThemeOptionsAdapter $options, FontCatalog $fonts, DesignChange $change)` (wie Vertrag), `lit/divi-theme-options-update`, Fähigkeiten `[ThemeOptionRead, ThemeOptionWrite]`. - Testhilfen für spätere Tests: `ThemeOptionsAreaTest::stored()` (`body_font` „Open Sans“, `body_font_size` 16, `body_font_height` 1.8), `ThemeOptionsAreaTest::adapter(array &$stored, array &$writes, ?Closure $sanitize = null, bool $customize = true)` (zustandsbehafteter Fake: `write()` setzt/löscht wie `et_update_option`/`et_delete_option` und liefert das Zurückgelesene), `FontCheckTest::catalog()` (`FontCatalog`-Fake über `Lato, Open Sans, Roboto, Roboto Mono, Roboto Slab`, `has()` exakt). **Regeln dieses Tasks (alle durch Tests belegt):** - `ThemeOptionsArea::write()` ersetzt immer die ganze Whitelist: Schlüssel des Bestands → `$set`, Whitelist-Schlüssel, die fehlen → `$unset` (W6); `$mayRemove` ist bedeutungslos, weil ein fehlender Schlüssel „zurück auf Divi-Standard“ heißt (W3). `restore()` = `write($raw, true)`. Ergebnis ist das, was `ThemeOptionsAdapter::write()` zurückliest (kein zweiter Lesezugriff). - `verify()` verlangt dieselben Schlüssel und dieselben Werte; toleriert wird nur eine Zahl in anderem Typ (`15`, `"15"`, `15.0`) – die Werte des Plans sind schon die Ausgabe von Divis Sanitizer, also ist jede andere Abweichung ein Fehler (→ Rollback). `false` ≠ `''`, Schriftnamen exakt. `differences()` nennt die tolerierten Typwechsel als Warnung („Divi hat die Theme-Option „Textgröße“ als „16“ statt 16 gespeichert.“). - `theme-options-get` liefert je Schlüssel (alle oder `keys`, immer in der Reihenfolge von `ThemeOptionSpec::KEYS`) `key, label, type, value` (gespeicherter Rohwert, `null` = nicht gesetzt), `effective` (`ThemeOptionSpec::effective` auf dem Bestand), `default` (`effective` auf dem Bestand ohne diesen Schlüssel – bei Tablet/Smartphone also die geerbte Größe, W3), `is_default` (nicht gespeichert) und die Felder `min, max, step, choices, inherits` der Definition, soweit vorhanden; dazu `design_rev` (`ThemeOptionBook::rev()`, dieselbe wie die des Probelaufs) und `notes` (Update-Tool, `null`, Vererbung, Hauptfarben → `lit/divi-colors-set`). Die fünf Hauptfarben selbst liest das Tool nicht (kein Leser im Vertrag; der Hinweis genügt). Recht: `edit_posts` in `permission()` **und** `execute()`, vor jedem Lesen. - `theme-options-update`: Eingabe `{set: {: Wert|null, …}, confirm?, design_rev?}`. `set` muss ein nicht leeres Objekt sein. Gesammelt mit Pfad `set.`: unbekannte Schlüssel (`ItemKeys`, „unbekanntes Feld“; bei `delete` mit Hinweis auf `null`), `false` und `'none'` (Hinweis auf `null`, W3), `ThemeOptionSpec::check()`, bei den vier Schriftschlüsseln `FontCheck` (vor Divis Sanitizer, der nur `false` liefern würde), dann `ThemeOptionsAdapter::sanitize()` auf den normalisierten Wert: kein Skalar/`false` → „Divi lehnt den Wert ab“, abweichend (außer Zahl in anderem Typ) → „Divi würde den Wert verändert speichern (…)“. Gespeichert wird die Ausgabe des Sanitizers (Typ von Divi, W4). `null` → `without()` (Divi: `et_delete_option`). - Ein nicht gesetzter Tablet-/Smartphone-Schlüssel, dessen neuer Wert der geerbten Größe gleicht, bleibt ungesetzt (kein Diff); weil die Schlüssel in der Reihenfolge von `KEYS` (Desktop vor Tablet vor Smartphone) angewendet werden, zählt dabei die Desktop-Größe desselben Aufrufs. - Plan: `ThemeOptionDiff::between/summary`, `usages = null` (kein `affected`-Inhalt, DesignChange setzt `affected: []`), `mayRemove = false`; Warnungen nur bei Änderungen: immer `ThemeOptionsArea::SITE_WIDE`, bei einer Schrift zusätzlich der Builder-Hinweis (W7). - Fähigkeiten des Updates `[ThemeOptionRead, ThemeOptionWrite]`: das Tool liest `et_divi` und die Customizer-Tabelle (ThemeOptionRead), ruft Sanitizer, Schriftliste und Schreiben (ThemeOptionWrite, W1). `DesignRead` braucht es nicht – es liest weder Presets noch das Designsystem (anders als `lit/divi-colors-set`, dessen Warnungen Presets lesen). #### Task 4a: `ThemeOptionsArea` – der Bereich `theme_options` für die Pipeline - [ ] **Step 4a.1: Failing Test `ThemeOptionsAreaTest` schreiben** Datei `tests/Unit/Integrations/Divi/Design/ThemeOptionsAreaTest.php`. Der Adapter ist ein zustandsbehafteter Fake (Vorbild `ColorAreaTest::adapter()`). ```php */ public static function stored(): array { return ['body_font' => 'Open Sans', 'body_font_size' => 16, 'body_font_height' => 1.8]; } /** * A ThemeOptionsAdapter over $stored: write() sets and deletes like et_update_option/et_delete_option, notes each * call in $writes and returns the read-back; sanitize() runs $sanitize (default: Divi keeps the value). * * @param array $stored * @param list, 1: list}> $writes * @param (Closure(string, mixed): mixed)|null $sanitize */ public static function adapter( array &$stored, array &$writes, ?Closure $sanitize = null, bool $customize = true ): ThemeOptionsAdapter { $options = Mockery::mock(ThemeOptionsAdapter::class); $options->allows('canCustomize')->andReturn($customize); $options->allows('read')->andReturnUsing(static function () use (&$stored): array { return $stored; }); $options->allows('sanitize') ->andReturnUsing($sanitize ?? static fn (string $key, mixed $value): mixed => $value); $options->allows('write')->andReturnUsing( static function (array $set, array $unset) use (&$stored, &$writes): array { $writes[] = [$set, $unset]; $stored = array_diff_key(array_merge($stored, $set), array_flip($unset)); return $stored; } ); return $options; } private function area(array &$stored, array &$writes): ThemeOptionsArea { return new ThemeOptionsArea(self::adapter($stored, $writes)); } public function testAreaReadsThroughTheAdapter(): void { $stored = self::stored(); $writes = []; $area = $this->area($stored, $writes); self::assertSame(['theme_options', 'Theme-Optionen'], [$area->name(), $area->label()]); self::assertSame(DiviFeature::ThemeOptionWrite, $area->feature()); self::assertSame(ThemeOptionBook::fromDivi($stored)->data(), $area->raw()); self::assertSame(ThemeOptionBook::fromDivi($stored)->rev(), $area->rev($stored)); } /** Ruling W6: keys of the planned inventory are set, whitelist keys missing from it are deleted. */ public function testWriteSetsThePlannedKeysAndDeletesTheMissingOnes(): void { $stored = self::stored(); $writes = []; $planned = ['heading_font' => 'Roboto', 'body_font' => 'Open Sans', 'body_font_size' => 18]; $saved = $this->area($stored, $writes)->write($planned, false); self::assertCount(1, $writes); self::assertSame(ThemeOptionBook::fromDivi($planned)->data(), $writes[0][0]); self::assertSame(array_values(array_diff(ThemeOptionSpec::KEYS, array_keys($planned))), $writes[0][1]); self::assertContains('body_font_height', $writes[0][1]); self::assertSame(ThemeOptionBook::fromDivi($planned)->data(), $saved); self::assertArrayNotHasKey('body_font_height', $stored); } /** Ruling W6: a restore (and a rollback) replaces the whole whitelist with the snapshot. */ public function testRestoreReplacesTheWholeWhitelistWithTheSnapshot(): void { $stored = ['body_font_size' => 20, 'tablet_body_font_size' => 18, 'gutter_width' => 2]; $writes = []; $snapshot = ThemeOptionBook::fromDivi(self::stored())->data(); $saved = $this->area($stored, $writes)->restore($snapshot); self::assertSame($snapshot, $saved); self::assertSame($snapshot, ThemeOptionBook::fromDivi($stored)->data()); self::assertContains('tablet_body_font_size', $writes[0][1]); self::assertContains('gutter_width', $writes[0][1]); } /** verify() tolerates only a number stored as another type (15 / "15" / 15.0), nothing else. */ public function testVerifyInsistsOnTheSameKeysAndValues(): void { $stored = self::stored(); $writes = []; $area = $this->area($stored, $writes); $expected = self::stored(); self::assertTrue($area->verify($expected, $expected)); self::assertTrue($area->verify($expected, ['body_font_size' => '16', 'body_font_height' => '1.8'] + $expected)); self::assertFalse($area->verify($expected, ['body_font_size' => 15] + $expected), 'changed value'); self::assertFalse($area->verify($expected, ['body_font' => 'open sans'] + $expected), 'fonts are exact'); $missing = $expected; unset($missing['body_font_height']); self::assertFalse($area->verify($expected, $missing), 'missing key'); self::assertFalse($area->verify($expected, $expected + ['gutter_width' => 3]), 'added key'); self::assertFalse($area->verify(['heading_font' => false], ['heading_font' => '']), 'false is not ""'); } public function testDifferencesNameTheValuesDiviStoredAsAnotherType(): void { $stored = self::stored(); $writes = []; $area = $this->area($stored, $writes); $expected = self::stored(); self::assertSame([], $area->differences($expected, $expected)); self::assertSame( [sprintf( 'Divi hat die Theme-Option „%s“ als „16“ statt 16 gespeichert.', ThemeOptionSpec::definition('body_font_size')['label'] )], $area->differences($expected, ['body_font_size' => '16'] + $expected) ); } } ``` - [ ] **Step 4a.2: RED-Lauf** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ThemeOptionsAreaTest` Expected: `Tests: 5, Assertions: 0, Errors: 5.` mit `Error: Class "LIT\MCP\Integrations\Divi\Design\ThemeOptionsArea" not found`. - [ ] **Step 4a.3: `ThemeOptionsArea` implementieren** Datei `src/Integrations/Divi/Design/ThemeOptionsArea.php`. Reines PHP (nur der Adapter spricht mit WordPress/Divi). ```php options->read())->data(); } public function rev(array $raw): string { return ThemeOptionBook::fromDivi($raw)->rev(); } /** $mayRemove has no effect: a key missing from the planned inventory is a reset to Divi's default (W3). */ public function write(array $raw, bool $mayRemove): array { $planned = ThemeOptionBook::fromDivi($raw)->data(); $saved = $this->options->write( $planned, array_values(array_diff(ThemeOptionSpec::KEYS, array_keys($planned))) ); return ThemeOptionBook::fromDivi($saved)->data(); } public function restore(array $raw): array { return $this->write($raw, true); } public function verify(array $expected, array $actual): bool { $expected = ThemeOptionBook::fromDivi($expected)->data(); $actual = ThemeOptionBook::fromDivi($actual)->data(); if (array_keys($expected) !== array_keys($actual)) { return false; } foreach ($expected as $key => $value) { if (! self::same($value, $actual[$key])) { return false; } } return true; } public function differences(array $expected, array $actual): array { $actual = ThemeOptionBook::fromDivi($actual)->data(); $warnings = []; foreach (ThemeOptionBook::fromDivi($expected)->data() as $key => $value) { if (array_key_exists($key, $actual) && $actual[$key] !== $value) { $warnings[] = sprintf( 'Divi hat die Theme-Option „%s“ als %s statt %s gespeichert.', ThemeOptionSpec::definition($key)['label'], self::shown($actual[$key]), self::shown($value) ); } } return $warnings; } /** Whether Divi keeps $b as $a: equal, or the same number of another type (absint, floatval, a digit string). */ public static function same(mixed $a, mixed $b): bool { return $a === $b || (is_numeric($a) && is_numeric($b) && (float) $a === (float) $b); } private static function shown(int|float|string|false $value): string { return match (true) { is_string($value) => '„' . $value . '“', $value === false => 'false', default => (string) $value, }; } } ``` - [ ] **Step 4a.4: GREEN-Lauf** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ThemeOptionsAreaTest` Expected: `OK (5 tests, 23 assertions)`. - [ ] **Step 4a.5: Lint** Run: `composer lint` Expected: keine Meldung. - [ ] **Step 4a.6: Commit** ```bash git add src/Integrations/Divi/Design/ThemeOptionsArea.php tests/Unit/Integrations/Divi/Design/ThemeOptionsAreaTest.php git commit -m "feat: ThemeOptionsArea – Bereich theme_options ersetzt die Whitelist, prüft das Zurücklesen" \ -m "Co-Authored-By: Claude " ``` #### Task 4b: `lit/divi-theme-options-get` – Theme-Optionen lesen - [ ] **Step 4b.1: Failing Test `DiviThemeOptionsGetAbilityTest` schreiben** Datei `tests/Unit/Integrations/Divi/Design/DiviThemeOptionsGetAbilityTest.php`. ```php justReturn(true); } private static function ability(): DiviThemeOptionsGetAbility { $stored = ThemeOptionsAreaTest::stored(); $writes = []; return new DiviThemeOptionsGetAbility(ThemeOptionsAreaTest::adapter($stored, $writes)); } /** * @param array $result * @return array> key => row */ private static function byKey(array $result): array { return array_column($result['options'], null, 'key'); } public function testSchemaAndMeta(): void { $ability = self::ability(); $schema = $ability->inputSchema(); self::assertSame('lit/divi-theme-options-get', $ability->name()); self::assertFalse($ability->isDirect()); self::assertSame(Annotations::read(), $ability->annotations()); self::assertSame([DiviFeature::ThemeOptionRead], $ability->diviFeatures([])); self::assertSame(ThemeOptionSpec::KEYS, $schema['properties']['keys']['items']['enum']); self::assertFalse($schema['additionalProperties']); self::assertSame(['options', 'design_rev', 'notes'], $ability->outputSchema()['required']); self::assertStringContainsString('lit/divi-theme-options-update', $ability->description()); } /** Reading needs what lit/divi-design-system needs (edit_posts) – checked before Divi is asked. */ public function testNeedsEditPosts(): void { Functions\when('current_user_can')->justReturn(false); $options = Mockery::mock(ThemeOptionsAdapter::class); $options->shouldNotReceive('read'); $ability = new DiviThemeOptionsGetAbility($options); self::assertToolError($ability->permission([]), 'forbidden'); self::assertToolError($ability->execute([]), 'forbidden'); } public function testListsEveryOptionWithValueEffectiveValueAndDefault(): void { $result = self::ability()->execute([]); self::assertSame(ThemeOptionSpec::KEYS, array_column($result['options'], 'key')); self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->rev(), $result['design_rev']); $rows = self::byKey($result); $size = ThemeOptionSpec::definition('body_font_size'); $expected = ['key' => 'body_font_size', 'label' => $size['label'], 'type' => 'int', 'value' => 16, 'effective' => 16, 'default' => ThemeOptionSpec::effective('body_font_size', []), 'is_default' => false, 'min' => $size['min'], 'max' => $size['max'], 'step' => $size['step']]; $actual = $rows['body_font_size']; ksort($expected); ksort($actual); self::assertSame($expected, $actual); self::assertSame('Open Sans', $rows['body_font']['value']); } /** Ruling W3: an unset tablet size inherits the desktop size – as effective value and as its default. */ public function testUnsetSizesInheritTheLargerSize(): void { $tablet = self::byKey(self::ability()->execute([]))['tablet_body_font_size']; self::assertNull($tablet['value']); self::assertTrue($tablet['is_default']); self::assertSame(16, $tablet['effective']); self::assertSame(16, $tablet['default']); self::assertSame('body_font_size', $tablet['inherits']); } public function testChoicesAreListed(): void { $rows = self::byKey(self::ability()->execute([])); self::assertSame( ThemeOptionSpec::definition('body_header_style')['choices'], $rows['body_header_style']['choices'] ); self::assertArrayNotHasKey('choices', $rows['body_font']); } public function testKeysNarrowTheAnswerInTheFixedOrder(): void { $result = self::ability()->execute(['keys' => ['row_padding', 'body_font']]); self::assertSame(['body_font', 'row_padding'], array_column($result['options'], 'key')); } public function testUnknownKeysAreRefused(): void { $error = self::ability()->execute(['keys' => ['accent_color', 'body_font', 5]]); self::assertToolError($error, 'validation_failed'); self::assertSame(['keys[0]', 'keys[2]'], array_keys(self::errorFields($error))); self::assertToolError(self::ability()->execute(['keys' => 'body_font']), 'validation_failed'); } /** G3, A8: the main colors are not theme options here; the notes point to lit/divi-colors-set. */ public function testNotesPointToTheColorTool(): void { $notes = implode(' ', self::ability()->execute([])['notes']); self::assertStringContainsString('lit/divi-colors-set', $notes); self::assertStringContainsString('null', $notes); } } ``` - [ ] **Step 4b.2: RED-Lauf** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviThemeOptionsGetAbilityTest` Expected: `Tests: 8, Assertions: 0, Errors: 8.` mit `Error: Class "LIT\MCP\Integrations\Divi\Design\DiviThemeOptionsGetAbility" not found`. - [ ] **Step 4b.3: `DiviThemeOptionsGetAbility` implementieren** Datei `src/Integrations/Divi/Design/DiviThemeOptionsGetAbility.php`. ```php 'object', 'properties' => [ 'keys' => [ 'type' => 'array', 'items' => ['type' => 'string', 'enum' => ThemeOptionSpec::KEYS], 'description' => 'Nur diese Theme-Optionen liefern (Standard: alle).', ], ], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return [ 'type' => 'object', 'properties' => [ 'options' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'key' => ['type' => 'string'], 'label' => ['type' => 'string'], 'type' => ['type' => 'string'], 'is_default' => ['type' => 'boolean'], ], 'required' => ['key', 'label', 'type', 'value', 'effective', 'default', 'is_default'], ], ], 'design_rev' => ['type' => 'string'], 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], ], 'required' => ['options', 'design_rev', 'notes'], ]; } public function annotations(): array { return Annotations::read(); } public function isDirect(): bool { return false; } /** Reads et_divi and the Customizer table (ThemeOptionsAdapter, ThemeOptionSpec mirror). */ public function diviFeatures(array $input): array { return [DiviFeature::ThemeOptionRead]; } public function permission(array $input): bool|WP_Error { return self::rights() ?? true; } public function execute(array $input): mixed { $refusal = self::rights(); if ($refusal !== null) { return $refusal; } $keys = self::keys($input); if ($keys instanceof WP_Error) { return $keys; } $book = ThemeOptionBook::fromDivi($this->options->read()); $rows = []; foreach ($keys as $key) { $definition = ThemeOptionSpec::definition($key); $rows[] = [ 'key' => $key, 'label' => $definition['label'], 'type' => $definition['type'], 'value' => $book->get($key), 'effective' => ThemeOptionSpec::effective($key, $book->data()), 'default' => ThemeOptionSpec::effective($key, $book->without($key)->data()), 'is_default' => ! $book->has($key), ] + array_intersect_key($definition, array_flip(self::RULES)); } return ['options' => $rows, 'design_rev' => $book->rev(), 'notes' => self::NOTES]; } /** Like lit/divi-design-system: whoever edits content may read the design. */ private static function rights(): ?WP_Error { return current_user_can('edit_posts') ? null : Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.'); } /** * @param array $input * @return list|WP_Error The requested keys in ThemeOptionSpec::KEYS order (all without "keys"). */ private static function keys(array $input): array|WP_Error { $keys = $input['keys'] ?? ThemeOptionSpec::KEYS; if (! is_array($keys) || ! array_is_list($keys)) { return Errors::invalidInput('keys muss eine Liste sein.', ['keys' => 'Liste von Theme-Optionen']); } $errors = []; foreach ($keys as $index => $key) { if (! is_string($key) || ! ThemeOptionSpec::isKey($key)) { $errors[sprintf('keys[%d]', $index)] = 'unbekannte Theme-Option; erlaubt sind die Schlüssel aus ' . 'lit/divi-theme-options-get ohne keys'; } } if ($errors !== []) { return Errors::invalidInput('Unbekannte Theme-Optionen.', $errors); } return array_values(array_intersect(ThemeOptionSpec::KEYS, $keys)); } } ``` - [ ] **Step 4b.4: GREEN-Lauf** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviThemeOptionsGetAbilityTest` Expected: `OK (8 tests, 38 assertions)`. - [ ] **Step 4b.5: Lint** Run: `composer lint` Expected: keine Meldung. - [ ] **Step 4b.6: Commit** ```bash git add src/Integrations/Divi/Design/DiviThemeOptionsGetAbility.php \ tests/Unit/Integrations/Divi/Design/DiviThemeOptionsGetAbilityTest.php git commit -m "feat: lit/divi-theme-options-get liest Wert, wirksamen Wert, Standard und Bereich je Theme-Option" \ -m "Co-Authored-By: Claude " ``` #### Task 4c: `FontCheck` und `lit/divi-theme-options-update` – Theme-Optionen ändern - [ ] **Step 4c.1: Failing Tests `FontCheckTest` und `DiviThemeOptionsUpdateAbilityTest` schreiben** Datei `tests/Unit/Integrations/Divi/Design/FontCheckTest.php`: ```php allows('names')->andReturn(self::NAMES); $fonts->allows('has')->andReturnUsing(static fn (string $name): bool => in_array($name, self::NAMES, true)); return $fonts; } public function testAKnownFontIsFine(): void { $fonts = self::catalog(); $fonts->shouldNotReceive('names'); self::assertNull(FontCheck::problem($fonts, 'Roboto Mono')); } /** Ruling W5: the name is exact – "roboto" is unknown, but gets "Roboto" as suggestion. */ public function testAnUnknownFontGetsSimilarNames(): void { self::assertSame( sprintf( 'unbekannte Schrift „roboto“; ähnlich: %s', implode(', ', FontSuggest::similar('roboto', self::NAMES)) ), FontCheck::problem(self::catalog(), 'roboto') ); self::assertStringContainsString('Roboto', (string) FontCheck::problem(self::catalog(), 'roboto')); } public function testWithoutSimilarNamesTheUploadToolIsNamed(): void { self::assertSame( 'unbekannte Schrift „Xyzzy Grotesk“; eigene Schriften lädt lit/divi-fonts-upload hoch', FontCheck::problem(self::catalog(), 'Xyzzy Grotesk') ); } } ``` Datei `tests/Unit/Integrations/Divi/Design/DiviThemeOptionsUpdateAbilityTest.php`. Jeder Test erreicht den Pfad, den er nennt: der Sanitizer-Fake protokolliert, welche Schlüssel Divi erreichen; der Rollback-Test lässt das erste `write()` scheitern und prüft, dass das zweite den Snapshot schreibt. ```php What the adapter stores (ThemeOptionsAreaTest::adapter()). */ private array $stored = []; /** @var list, 1: list}> */ private array $writes = []; protected function setUp(): void { parent::setUp(); // DesignLock (final review I-5): the lock is always free here. Functions\when('add_option')->justReturn(true); Functions\when('delete_option')->justReturn(true); Functions\when('current_user_can')->justReturn(true); $this->stored = ThemeOptionsAreaTest::stored(); $this->writes = []; } /** @param (Closure(string, mixed): mixed)|null $sanitize Divi's sanitizer (default: keeps the value). */ private function ability( ?SnapshotStore $snapshots = null, ?Closure $sanitize = null, bool $customize = true, ?ThemeOptionsAdapter $options = null ): DiviThemeOptionsUpdateAbility { $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('canUseVisualBuilder')->andReturn(true); return new DiviThemeOptionsUpdateAbility( $writer, $options ?? ThemeOptionsAreaTest::adapter($this->stored, $this->writes, $sanitize, $customize), FontCheckTest::catalog(), new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)) ); } /** A SnapshotStore that expects one snapshot of the stored options and the log ID 3 attached to it. */ private static function snapshots(): SnapshotStore { $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->expects('save') ->with( 'theme_options', ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->data(), 'lit/divi-theme-options-update' ) ->andReturn('theme_options-1'); $snapshots->expects('attachLog')->with('theme_options', 'theme_options-1', 3); return $snapshots; } /** * Dry run, then confirm with its design_rev. * * @param array $input * @return array{0: array, 1: mixed} */ private function confirmed(DiviThemeOptionsUpdateAbility $ability, array $input): array { $dry = self::assertToolError($ability->execute($input), 'confirm_required'); return [$dry, $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']])]; } public function testSchemaAndMeta(): void { $ability = $this->ability(); $properties = $ability->inputSchema()['properties']; self::assertSame('lit/divi-theme-options-update', $ability->name()); self::assertFalse($ability->isDirect()); self::assertSame(Annotations::destructive(false), $ability->annotations()); self::assertSame([DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $ability->diviFeatures([])); self::assertSame(ThemeOptionSpec::KEYS, array_keys($properties['set']['properties'])); self::assertFalse($properties['set']['additionalProperties']); self::assertSame(10, $properties['set']['properties']['body_font_size']['minimum']); self::assertSame(32, $properties['set']['properties']['body_font_size']['maximum']); self::assertContains('null', $properties['set']['properties']['heading_font']['type']); self::assertArrayHasKey('design_rev', $properties); self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2)); self::assertStringContainsString('confirm: true mit design_rev', $ability->description()); self::assertStringContainsString('lit/divi-colors-set', $ability->description()); } /** Spec §10, W14: edit_theme_options and Divi's role right "Theme-Anpassung", before anything is read. */ public function testNeedsEditThemeOptionsAndTheCustomizerRight(): void { $input = ['set' => ['body_font_size' => 18]]; self::assertToolError($this->ability(customize: false)->execute($input), 'forbidden'); self::assertToolError($this->ability(customize: false)->permission($input), 'forbidden'); Functions\when('current_user_can')->justReturn(false); self::assertToolError($this->ability()->execute($input), 'forbidden'); self::assertToolError($this->ability()->permission($input), 'forbidden'); self::assertSame([], $this->writes); } public function testSetMustBeAnObjectWithAtLeastOneKey(): void { foreach ([[], ['set' => []], ['set' => ['body_font']], ['set' => 'body_font']] as $input) { self::assertToolError($this->ability()->execute($input), 'validation_failed'); } } public function testDryRunThenConfirmWritesTheOptions(): void { $input = ['set' => ['heading_font' => 'Roboto', 'body_font_size' => '18']]; [$dry, $result] = $this->confirmed($this->ability(self::snapshots()), $input); self::assertSame( [['heading_font', 'added'], ['body_font_size', 'changed']], array_map(static fn (array $row): array => [$row['key'], $row['action']], $dry['diff']) ); self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->rev(), $dry['design_rev']); self::assertStringContainsString(ThemeOptionDiff::summary($dry['diff']), $dry['message']); self::assertSame([], $dry['affected'], 'theme options have no page reference (W7)'); self::assertSame(ThemeOptionsArea::SITE_WIDE, $dry['warnings'][0]); self::assertStringContainsString('Builder', $dry['warnings'][1]); self::assertSame('theme_options-1', $result['snapshot_id']); self::assertSame(18, $this->stored['body_font_size'], 'stored as the number Divi keeps'); self::assertSame('Roboto', $this->stored['heading_font']); self::assertSame(ThemeOptionBook::fromDivi($this->stored)->rev(), $result['design_rev']); } /** Only fonts reach the builder (W7). */ public function testSizesOnlyWarnAboutTheWholeWebsite(): void { $dry = self::assertToolError( $this->ability()->execute(['set' => ['content_width' => 1200]]), 'confirm_required' ); self::assertSame([ThemeOptionsArea::SITE_WIDE], $dry['warnings']); } /** Ruling W3: null resets to Divi's default – the key is deleted, not stored as "none" or false. */ public function testNullResetsToTheDiviDefault(): void { [$dry] = $this->confirmed($this->ability(self::snapshots()), ['set' => ['body_font_height' => null]]); self::assertSame(['reset'], array_column($dry['diff'], 'action')); self::assertContains('body_font_height', $this->writes[0][1]); self::assertArrayNotHasKey('body_font_height', $this->stored); } public function testNoneAndFalseAreRefusedWithAHint(): void { $error = $this->ability()->execute(['set' => ['heading_font' => false, 'body_font' => 'none']]); self::assertToolError($error, 'validation_failed'); $fields = self::errorFields($error); self::assertSame(['set.heading_font', 'set.body_font'], array_keys($fields)); self::assertStringContainsString('null', $fields['set.body_font']); } public function testAllProblemsAreCollectedAndUnknownKeysRefused(): void { $error = $this->ability()->execute(['set' => [ 'gutter_width' => 'breit', 'accent_color' => '#ffffff', 'delete' => true, 'body_font_size' => 99, 'body_header_style' => 'bold', ]]); self::assertToolError($error, 'validation_failed'); $fields = self::errorFields($error); self::assertSame( ['set.accent_color', 'set.delete', 'set.body_font_size', 'set.body_header_style', 'set.gutter_width'], array_keys($fields) ); self::assertSame('unbekanntes Feld', $fields['set.accent_color']); self::assertStringContainsString('null', $fields['set.delete']); self::assertSame([], $this->writes); } /** Ruling W5: the exact name of a Divi font; unknown names get suggestions before Divi's sanitizer is asked. */ public function testUnknownFontsGetSuggestions(): void { $asked = []; $sanitize = static function (string $key, mixed $value) use (&$asked): mixed { $asked[] = $key; return $value; }; $error = $this->ability(sanitize: $sanitize)->execute(['set' => [ 'heading_font' => 'robotto', 'body_font' => 'Xyzzy Grotesk', 'primary_nav_font' => 'Lato', ]]); self::assertToolError($error, 'validation_failed'); $fields = self::errorFields($error); self::assertSame(['set.heading_font', 'set.body_font'], array_keys($fields)); self::assertStringContainsString('Roboto', $fields['set.heading_font']); self::assertStringContainsString('lit/divi-fonts-upload', $fields['set.body_font']); self::assertSame(['primary_nav_font'], $asked, 'only the known font goes to Divi'); } /** Divi's sanitizer answers false or changes the value – Divi would store false or round (Global Constraints). */ public function testValuesDiviRejectsOrChangesAreErrors(): void { $sanitize = static fn (string $key, mixed $value): mixed => match ($key) { 'body_header_style' => false, 'content_width' => 1000, default => $value, }; $error = $this->ability(sanitize: $sanitize)->execute(['set' => [ 'body_header_style' => 'italic', 'content_width' => 1200, 'row_padding' => 3, ]]); self::assertToolError($error, 'validation_failed'); self::assertSame(['set.body_header_style', 'set.content_width'], array_keys(self::errorFields($error))); } /** Ruling W4: the value is stored with the type Divi's sanitizer returns. */ public function testTheSanitizedTypeIsStored(): void { $sanitize = static fn (string $key, mixed $value): mixed => $key === 'body_font_size' ? (string) $value : $value; $this->confirmed($this->ability(self::snapshots(), $sanitize), ['set' => ['body_font_size' => 20]]); self::assertSame('20', $this->stored['body_font_size']); } /** Ruling W3: a tablet/phone size equal to the size it inherits stays unset and keeps following it. */ public function testATabletSizeEqualToTheInheritedSizeIsNoChange(): void { $same = $this->ability()->execute(['set' => ['tablet_body_font_size' => 16]]); self::assertSame([], $same['diff']); self::assertStringStartsWith('Keine Änderung', $same['summary']); $dry = self::assertToolError( $this->ability()->execute(['set' => ['tablet_body_font_size' => 20, 'body_font_size' => 20]]), 'confirm_required' ); self::assertSame(['body_font_size'], array_column($dry['diff'], 'key'), 'inherits the new desktop size'); $own = self::assertToolError( $this->ability()->execute(['set' => ['tablet_body_font_size' => 15]]), 'confirm_required' ); self::assertSame([['tablet_body_font_size', 'added']], array_map( static fn (array $row): array => [$row['key'], $row['action']], $own['diff'] )); self::assertSame([], $this->writes); } public function testUnchangedValuesAreNoChange(): void { $result = $this->ability()->execute(['set' => ['body_font_size' => 16, 'gutter_width' => null]]); self::assertSame([], $result['diff']); self::assertSame([], $this->writes); } public function testConfirmNeedsTheDesignRevOfTheDryRun(): void { $input = ['set' => ['body_font_size' => 18], 'confirm' => true]; self::assertToolError($this->ability()->execute($input), 'validation_failed'); self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch'); self::assertSame([], $this->writes); } public function testAFailedWriteIsRolledBackToTheSnapshot(): void { $calls = []; $options = Mockery::mock(ThemeOptionsAdapter::class); $options->allows('canCustomize')->andReturn(true); $options->allows('read')->andReturn(ThemeOptionsAreaTest::stored()); $options->allows('sanitize')->andReturnUsing(static fn (string $key, mixed $value): mixed => $value); $options->allows('write')->andReturnUsing(static function (array $set, array $unset) use (&$calls): array { $calls[] = $set; if (count($calls) === 1) { throw new RuntimeException('Zurücklesen stimmt nicht'); } return $set; }); $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->expects('save')->andReturn('theme_options-1'); try { $this->confirmed($this->ability($snapshots, options: $options), ['set' => ['body_font_size' => 18]]); self::fail('expected WriteFailed'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); self::assertSame('theme_options-1', $e->details['snapshot_id']); } self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->data(), $calls[1]); } } ``` - [ ] **Step 4c.2: RED-Lauf** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'FontCheckTest|DiviThemeOptionsUpdateAbilityTest'` Expected: `Tests: 18, Assertions: 0, Errors: 18.` mit `Error: Class "LIT\MCP\Integrations\Divi\Design\FontCheck" not found` bzw. `… DiviThemeOptionsUpdateAbility" not found`. - [ ] **Step 4c.3: `FontCheck` implementieren** Datei `src/Integrations/Divi/Design/FontCheck.php`. Ruft nur die Adapter-Methoden; keine WordPress-/Divi-Aufrufe. ```php has($name)) { return null; } $similar = FontSuggest::similar($name, $fonts->names()); return sprintf('unbekannte Schrift „%s“', $name) . ($similar === [] ? '; eigene Schriften lädt lit/divi-fonts-upload hoch' : '; ähnlich: ' . implode(', ', $similar)); } } ``` - [ ] **Step 4c.4: `DiviThemeOptionsUpdateAbility` implementieren** Datei `src/Integrations/Divi/Design/DiviThemeOptionsUpdateAbility.php`. Das Eingabeschema entsteht aus `ThemeOptionSpec` (20 Schlüssel, Typen inkl. `null`, `minimum`/`maximum` der Zahlen, Auswahl in der Beschreibung, `additionalProperties: false`). ```php . Theme options have no page reference: the dry run warns about the whole website instead. */ final class DiviThemeOptionsUpdateAbility implements Ability, RequiresDiviFeatures { private const RESET_HINT = 'zum Zurücksetzen den Wert null angeben'; private const BUILDER = 'Geänderte Schriften wirken auch im Divi-Builder (Standardschrift der Module).'; public function __construct( private DesignSystemWriter $writer, private ThemeOptionsAdapter $options, private FontCatalog $fonts, private DesignChange $change ) { } public function name(): string { return 'lit/divi-theme-options-update'; } public function label(): string { return 'Divi-Theme-Optionen ändern'; } public function description(): string { return 'Ändert Divis Theme-Optionen für Schriften, Schriftgrößen und Layout. set: {Schlüssel: Wert}; null ' . 'setzt auf den Divi-Standard zurück. Schlüssel, aktuelle Werte und Bereiche liefert ' . 'lit/divi-theme-options-get; Schriften mit dem exakten Namen einer Divi-Schrift (unbekannte Namen ' . 'bekommen Vorschläge). Die Hauptfarben ändert lit/divi-colors-set. Wirkt auf die gesamte Website: zuerst ' . 'Probelauf (diff, Warnungen, design_rev), nach Rückfrage beim Nutzer confirm: true mit design_rev. ' . 'Vorher wird ein Snapshot angelegt (lit/divi-design-restore).'; } public function inputSchema(): array { $keys = []; foreach (ThemeOptionSpec::KEYS as $key) { $keys[$key] = self::keySchema($key); } return [ 'type' => 'object', 'properties' => [ 'set' => [ 'type' => 'object', 'minProperties' => 1, 'properties' => $keys, 'additionalProperties' => false, 'description' => 'Theme-Option => neuer Wert; null = Divi-Standard.', ], ] + DesignChange::inputProperties(), 'required' => ['set'], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return DesignChange::outputSchema(); } public function annotations(): array { return Annotations::destructive(false); } public function isDirect(): bool { return false; } /** Reads et_divi and the Customizer table (ThemeOptionRead); sanitizer, font list and writing: ThemeOptionWrite. */ public function diviFeatures(array $input): array { return [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite]; } public function permission(array $input): bool|WP_Error { return DesignRights::themeOptions($this->writer, $this->options) ?? true; } public function execute(array $input): mixed { $refusal = DesignRights::themeOptions($this->writer, $this->options); if ($refusal !== null) { return $refusal; } $set = $input['set'] ?? null; if (! is_array($set) || $set === [] || array_is_list($set)) { return Errors::invalidInput( 'set fehlt oder ist leer; nichts wurde gespeichert.', ['set' => 'Objekt {Theme-Option: Wert}, Schlüssel aus lit/divi-theme-options-get'] ); } return $this->change->run( $this->name(), new ThemeOptionsArea($this->options), $input, fn (array $raw): DesignPlan|WP_Error => $this->plan(ThemeOptionBook::fromDivi($raw), $set) ); } /** @param array $set */ private function plan(ThemeOptionBook $before, array $set): DesignPlan|WP_Error { $errors = []; ItemKeys::refuseUnknown($set, ThemeOptionSpec::KEYS, 'set', $errors, self::RESET_HINT); $book = $before; // In the fixed key order, so a tablet size is compared with the desktop size of this call. foreach (ThemeOptionSpec::KEYS as $key) { if (! array_key_exists($key, $set)) { continue; } if ($set[$key] === null) { $book = $book->without($key); continue; } $value = $this->value($key, $set[$key], $errors); if ($value !== null && ! self::inherited($book, $key, $value)) { $book = $book->with($key, $value); } } if ($errors !== []) { return Errors::invalidInput('Ungültige Theme-Optionen; nichts wurde gespeichert.', $errors); } $diff = ThemeOptionDiff::between($before, $book); return new DesignPlan($book->data(), $diff, ThemeOptionDiff::summary($diff), null, self::warnings($diff)); } /** * The value Divi stores for $key, or null after noting the problem in $errors. * * @param array $errors */ private function value(string $key, mixed $value, array &$errors): int|float|string|null { $field = 'set.' . $key; if ($value === false || $value === 'none') { $errors[$field] = 'für den Divi-Standard null angeben, nicht false oder "none"'; return null; } $problem = ThemeOptionSpec::check($key, $value); if ($problem === null && ThemeOptionSpec::definition($key)['type'] === 'font') { // Before Divi's sanitizer: it would only answer false, without suggestions. $problem = FontCheck::problem($this->fonts, (string) $value); } if ($problem !== null) { $errors[$field] = $problem; return null; } $normalized = ThemeOptionSpec::normalize($key, $value); $sanitized = $this->options->sanitize($key, $normalized); if (! is_int($sanitized) && ! is_float($sanitized) && ! is_string($sanitized)) { $errors[$field] = 'Divi lehnt den Wert ab'; return null; } if (! ThemeOptionsArea::same($sanitized, $normalized)) { $errors[$field] = sprintf('Divi würde den Wert verändert speichern (%s)', $sanitized); return null; } return $sanitized; } /** Ruling W3: an unset tablet/phone size given the size it inherits stays unset – it keeps following that size. */ private static function inherited(ThemeOptionBook $book, string $key, int|float|string $value): bool { return isset(ThemeOptionSpec::definition($key)['inherits']) && ! $book->has($key) && ThemeOptionsArea::same(ThemeOptionSpec::effective($key, $book->data()), $value); } /** * Ruling W7: no affected pages, but the reach of the change. * * @param list> $diff * @return list */ private static function warnings(array $diff): array { if ($diff === []) { return []; } foreach ($diff as $row) { if (ThemeOptionSpec::definition((string) $row['key'])['type'] === 'font') { return [ThemeOptionsArea::SITE_WIDE, self::BUILDER]; } } return [ThemeOptionsArea::SITE_WIDE]; } /** @return array The schema of one key: its types, the range of numbers, a description. */ private static function keySchema(string $key): array { $definition = ThemeOptionSpec::definition($key); $schema = ['type' => match ($definition['type']) { 'int' => ['integer', 'string', 'null'], 'float' => ['number', 'string', 'null'], 'weight' => ['string', 'integer', 'null'], default => ['string', 'null'], }]; if (isset($definition['min'], $definition['max'])) { $schema['minimum'] = $definition['min']; $schema['maximum'] = $definition['max']; } $schema['description'] = $definition['label'] . (isset($definition['choices']) ? ': ' . implode(', ', $definition['choices']) : ''); return $schema; } } ``` - [ ] **Step 4c.5: GREEN-Lauf** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'FontCheckTest|DiviThemeOptionsUpdateAbilityTest'` Expected: `OK (18 tests, 149 assertions)`. - [ ] **Step 4c.6: Lint** Run: `composer lint` Expected: keine Meldung. - [ ] **Step 4c.7: Commit** ```bash git add src/Integrations/Divi/Design/FontCheck.php src/Integrations/Divi/Design/DiviThemeOptionsUpdateAbility.php \ tests/Unit/Integrations/Divi/Design/FontCheckTest.php \ tests/Unit/Integrations/Divi/Design/DiviThemeOptionsUpdateAbilityTest.php git commit -m "feat: lit/divi-theme-options-update ändert Theme-Optionen über DesignChange (Prüfung, Divi-Sanitizer, Schriftvorschläge)" \ -m "Co-Authored-By: Claude " ``` #### Task 4 – Abschluss - [ ] **Step 4.8: Gesamtlauf** Run: `composer test && composer lint` Expected: alle Unit-Tests grün – Stand nach Task 3 + 31 neue Tests (Probelauf des Planers mit Platzhaltern für Tasks 1–3: 3121 → 3152), Lint ohne Meldung. Schlägt `DiviBoundaryTest` oder `CoreIndependenceTest` an, steht in den neuen Klassen ein Divi-/WordPress-Aufruf, der in den Adapter gehört – entfernen, nicht den Test anpassen. Die neuen Tools sind noch nicht registriert (Task 9); `DiviToolFeaturesTest` ändert sich deshalb hier nicht. --- ### Task 5: Schriftprüfung der Schrift-Variablen in `lit/divi-variables-set` (W5, W12) **Files:** - Modify: `src/Integrations/Divi/Design/DiviVariablesSetAbility.php` (Konstruktor, `value()`, Docblock), `src/Integrations/Divi/Design/VariableValue.php` (nur Docblock, Zeile 10–12), `src/Integrations/Divi/DiviIntegration.php` (nur das Konstruktor-Argument, damit die Suite grün bleibt) - Test: `tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php` (Hilfe `ability()` + vier neue Tests) **Interfaces:** - Consumes: `FontCatalog` (Task 2), `FontCheck::problem()` (Task 4c), `FontCheckTest::catalog()` (Task 4c). - Produces: **neue Konstruktor-Signatur** `DiviVariablesSetAbility::__construct(DesignSystemWriter $writer, GlobalVariablesAdapter $variables, DesignChange $change, GlobalUsages $usages, FontCatalog $fonts, ?Closure $random = null)` – `FontCatalog` steht vor dem optionalen `$random` (nur Tests übergeben es). Task 9 ersetzt das hier eingesetzte `new FontCatalog()` durch die gemeinsame Instanz der Integration (eine Schriftliste je Request für Variablen, Theme-Optionen und Upload). - `diviFeatures()` bleibt `[DesignRead, VariableWrite]`: die Schriftlisten-Aufrufe tragen laut W1 auch `variable-write`; ein Bruch der Liste sperrt das Tool bewusst. **Regeln:** Nur Variablen vom Typ `fonts` und nur, wenn `VariableValue::check()` den Wert schon angenommen hat, gehen an `FontCheck` (exakter Name aus `FontCatalog`, sonst Fehler `items[i].value` mit Vorschlägen). Andere Typen und ein formal ungültiger Schriftname laden die Liste nicht (sie kostet 1,3 MB, W5). `VariableValue` bleibt rein. - [ ] **Step 5.1: Tests anpassen und ergänzen** In `tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php`: 1. Import ergänzen (nach `use LIT\MCP\Integrations\Divi\Design\DiviVariablesSetAbility;`): ```php use LIT\MCP\Integrations\Divi\Design\FontCatalog; ``` 2. In `ability()` den Parameter `bool $manage = true` ersetzen durch ```php bool $manage = true, ?FontCatalog $fonts = null ``` und im Konstruktoraufruf nach `$usages ?? self::usages(self::unused()),` einfügen: ```php $fonts ?? FontCheckTest::catalog(), ``` 3. Am Ende der Klasse (vor der letzten `}`) anfügen: ```php /** Ruling W12: a font variable names a font Divi knows exactly; unknown names get suggestions. */ public function testFontVariablesNeedAFontDiviKnows(): void { $error = $this->ability() ->execute(['items' => [['type' => 'fonts', 'label' => 'Marke', 'value' => 'robotto']]]); self::assertToolError($error, 'validation_failed'); $fields = self::errorFields($error); self::assertSame(['items[0].value'], array_keys($fields)); self::assertStringStartsWith('unbekannte Schrift „robotto“; ähnlich: ', $fields['items[0].value']); self::assertStringContainsString('Roboto', $fields['items[0].value']); self::assertSame([], $this->saves); } public function testAFontVariableWithAKnownFontIsPlanned(): void { $dry = self::assertToolError( $this->ability()->execute(['items' => [['type' => 'fonts', 'label' => 'Marke', 'value' => 'Roboto Slab']]]), 'confirm_required' ); self::assertSame(['added'], array_column($dry['diff'], 'action')); } /** The font list is loaded only for font variables (it costs 1.3 MB, ruling W5). */ public function testOtherTypesDoNotLoadTheFontList(): void { $fonts = Mockery::mock(FontCatalog::class); $fonts->shouldNotReceive('has', 'names'); self::assertToolError( $this->ability(fonts: $fonts)->execute(['items' => [ ['type' => 'strings', 'label' => 'Gruß', 'value' => 'Hallo'], ['id' => 'Eckenradius', 'value' => '12px'], ]]), 'confirm_required' ); } /** VariableValue checks first: a malformed name never reaches the font list. */ public function testAMalformedFontNameFailsBeforeTheFontList(): void { $fonts = Mockery::mock(FontCatalog::class); $fonts->shouldNotReceive('has', 'names'); $error = $this->ability(fonts: $fonts) ->execute(['items' => [['type' => 'fonts', 'label' => 'Marke', 'value' => 'Roboto; x']]]); self::assertToolError($error, 'validation_failed'); self::assertStringStartsWith('Schrift:', self::errorFields($error)['items[0].value']); } ``` - [ ] **Step 5.2: RED-Lauf** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviVariablesSetAbilityTest` Expected: `Tests: 21, Assertions: 17, Errors: 21.` mit `TypeError: …DiviVariablesSetAbility::__construct(): Argument #5 ($random) must be of type ?Closure, Mockery_…_FontCatalog given`. - [ ] **Step 5.3: `DiviVariablesSetAbility` erweitern** In `src/Integrations/Divi/Design/DiviVariablesSetAbility.php`: 1. Docblock der Klasse – den Satz zur Wertprüfung ersetzen: ```php * builder. Values are checked per type (VariableValue); an active variable needs one. The dry run lists the posts that * reference a changed, archived or activated variable (ReferenceUsageQuery) and warns about presets that use it. */ ``` durch ```php * builder. Values are checked per type (VariableValue), font variables also against Divi's font list (FontCheck, ruling * W12); an active variable needs one. The dry run lists the posts that reference a changed, archived or activated * variable (ReferenceUsageQuery) and warns about presets that use it. */ ``` 2. Konstruktor: nach `private GlobalUsages $usages,` einfügen ```php private FontCatalog $fonts, ``` 3. In `apply()` die Zeile `'value' => self::value($type, $item['value']),` ersetzen durch ```php 'value' => $this->value($type, $item['value']), ``` 4. `value()` wird eine Instanzmethode mit der Listenprüfung – den Anfang der Methode ersetzen: ```php /** @throws InvalidArgumentException */ private function value(string $type, mixed $value): string { if (is_int($value)) { $value = (string) $value; // an attachment ID for images } if (! is_string($value)) { throw new InvalidArgumentException('Wert als Text erwartet'); } $problem = VariableValue::check($type, $value); if ($problem === null && $type === 'fonts') { $problem = FontCheck::problem($this->fonts, $value); } ``` (der Rest – `if ($problem !== null) { throw … }` und `return $value;` – bleibt). 5. `src/Integrations/Divi/Design/VariableValue.php`, Docblock der Klasse: ```php * (new in Divi 5.15) is passed through by VariableBook, but the tools never create or change it. The check of fonts * against Divi's font list follows with 4d. Pure PHP. ``` ersetzen durch ```php * (new in Divi 5.15) is passed through by VariableBook, but the tools never create or change it. Font names are only * checked for their form here; DiviVariablesSetAbility checks them against Divi's font list (FontCheck, ruling W12). * Pure PHP. ``` 6. `src/Integrations/Divi/DiviIntegration.php` (minimal, damit `DiviIntegrationTest` grün bleibt; die gemeinsame Instanz folgt mit Task 9): Import `use LIT\MCP\Integrations\Divi\Design\FontCatalog;` (alphabetisch nach `DiviVariablesSetAbility`) und im Aufruf ```php new DiviVariablesSetAbility( $this->designWriter, $this->variablesAdapter, $this->designChange(), $this->globalUsages(), new FontCatalog() ), ``` - [ ] **Step 5.4: GREEN-Lauf** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DiviVariablesSetAbilityTest|DiviIntegrationTest'` Expected: `OK (36 tests, 344 assertions)` (21 + 15). - [ ] **Step 5.5: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` Expected: grün, Stand nach Task 4 + 4 Tests (Probe: 3156), Lint ohne Meldung. ```bash git add src/Integrations/Divi/Design/DiviVariablesSetAbility.php src/Integrations/Divi/Design/VariableValue.php \ src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php git commit -m "feat: lit/divi-variables-set prüft Schrift-Variablen gegen Divis Schriftliste (mit Vorschlägen)" \ -m "Co-Authored-By: Claude " ``` --- ### Task 6: `ThemeOptionsRestorePlanner` – Restore für `theme_options` (W6, W11) **Files:** - Create: `src/Integrations/Divi/Design/ThemeOptionsRestorePlanner.php` - Modify: `src/Integrations/Divi/Design/DiviDesignRestoreAbility.php` (Docblock, Beschreibung, Text des `unsupported`-Zweigs) - Test: `tests/Unit/Integrations/Divi/Design/ThemeOptionsRestorePlannerTest.php` (neu), `DiviDesignRestoreAbilityTest.php` (angepasst) **Interfaces:** - Consumes: `ThemeOptionsArea`, `ThemeOptionsArea::SITE_WIDE` (Task 4a), `ThemeOptionsAreaTest::adapter()`, `DesignRights::themeOptions()`, `ThemeOptionBook`, `ThemeOptionDiff`, `ThemeOptionSpec::definition()` (Tasks 2/3). - Produces: `final class ThemeOptionsRestorePlanner implements RestorePlanner` – **`__construct(DesignSystemWriter $writer, ThemeOptionsAdapter $options)`**; `area()` = `theme_options`, `designArea()` = `new ThemeOptionsArea($options)`, `rights()` = `DesignRights::themeOptions($writer, $options)`, `features()` = `[ThemeOptionRead, ThemeOptionWrite]` (wie das Update-Tool). Testhilfen `ThemeOptionsRestorePlannerTest::SNAPSHOT_ID`, `::current()`, `::snapshot()`. - `DiviDesignRestoreAbility::__construct(DesignChange, SnapshotStore, RestorePlanner ...$planners)` bleibt; Task 9 hängt in `DiviIntegration::abilities()` an die Planerliste `new ThemeOptionsRestorePlanner($this->designWriter, )` an (wie 4c Task 9 die Farb-/Variablen-Planer). Ohne diese Zeile bleibt `theme_options` `unsupported` – die Suite braucht sie nicht. **Regeln:** Der Snapshot ersetzt die ganze Whitelist (`mayRemove = true`); fehlt ein Schlüssel im Snapshot, ist das eine `reset`-Zeile und eine Warnung, die die Labels nennt. Zusammenfassung `Snapshot wiederherstellen – `. Bei Änderungen zusätzlich `SITE_WIDE`; ohne Änderung keine Warnung. Der `unsupported`-Zweig der Restore-Ability bleibt für Bereiche ohne Planer, ohne den Verweis auf Etappe 4d. #### Task 6a: `ThemeOptionsRestorePlanner` - [ ] **Step 6a.1: Failing Test `ThemeOptionsRestorePlannerTest` schreiben** Datei `tests/Unit/Integrations/Divi/Design/ThemeOptionsRestorePlannerTest.php`: ```php The options now: larger text, a gutter set after the snapshot. */ public static function current(): array { return ['body_font_size' => 18, 'gutter_width' => 2]; } /** @return array The snapshot: body font and text size 16, no gutter. */ public static function snapshot(): array { return ['body_font' => 'Open Sans', 'body_font_size' => 16]; } private static function planner(bool $customize = true): ThemeOptionsRestorePlanner { $stored = []; $writes = []; $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('canUseVisualBuilder')->andReturn(true); return new ThemeOptionsRestorePlanner( $writer, ThemeOptionsAreaTest::adapter($stored, $writes, null, $customize) ); } public function testAreaFeaturesAndDesignArea(): void { $planner = self::planner(); self::assertSame('theme_options', $planner->area()); self::assertSame([DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $planner->features()); self::assertInstanceOf(ThemeOptionsArea::class, $planner->designArea()); } /** Ruling W11: the rights of lit/divi-theme-options-update. */ public function testNeedsTheThemeOptionRights(): void { Functions\when('current_user_can')->justReturn(true); self::assertNull(self::planner()->rights()); self::assertToolError(self::planner(false)->rights(), 'forbidden'); Functions\when('current_user_can')->justReturn(false); self::assertToolError(self::planner()->rights(), 'forbidden'); } /** Ruling W6: the snapshot replaces the whole whitelist – keys it lacks return to Divi's default. */ public function testTheSnapshotReplacesEveryOptionAndNamesTheResetOnes(): void { $plan = self::planner()->plan(self::current(), self::snapshot(), self::SNAPSHOT_ID); self::assertInstanceOf(DesignPlan::class, $plan); self::assertTrue($plan->mayRemove); self::assertNull($plan->usages); self::assertSame(ThemeOptionBook::fromDivi(self::snapshot())->data(), $plan->raw); self::assertSame( [['body_font', 'added'], ['body_font_size', 'changed'], ['gutter_width', 'reset']], array_map(static fn (array $row): array => [$row['key'], $row['action']], $plan->diff) ); self::assertSame( 'Snapshot ' . self::SNAPSHOT_ID . ' wiederherstellen – ' . ThemeOptionDiff::summary($plan->diff), $plan->summary ); self::assertSame([ ThemeOptionsArea::SITE_WIDE, sprintf( 'Theme-Optionen, die im Snapshot nicht gesetzt sind, kehren zum Divi-Standard zurück („%s“).', ThemeOptionSpec::definition('gutter_width')['label'] ), ], $plan->warnings); } public function testTheSameOptionsAreNoChange(): void { $plan = self::planner()->plan(self::snapshot(), self::snapshot(), self::SNAPSHOT_ID); self::assertInstanceOf(DesignPlan::class, $plan); self::assertTrue($plan->isEmpty()); self::assertSame([], $plan->warnings); } } ``` - [ ] **Step 6a.2: RED-Lauf** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ThemeOptionsRestorePlannerTest` Expected: `Tests: 4, Assertions: 0, Errors: 4.` mit `Error: Class "LIT\MCP\Integrations\Divi\Design\ThemeOptionsRestorePlanner" not found`. - [ ] **Step 6a.3: `ThemeOptionsRestorePlanner` implementieren** Datei `src/Integrations/Divi/Design/ThemeOptionsRestorePlanner.php`: ```php options); } public function rights(): ?WP_Error { return DesignRights::themeOptions($this->writer, $this->options); } /** Like lit/divi-theme-options-update: reads et_divi (ThemeOptionRead), writes it (ThemeOptionWrite). */ public function features(): array { return [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite]; } public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error { $after = ThemeOptionBook::fromDivi($snapshot); $diff = ThemeOptionDiff::between(ThemeOptionBook::fromDivi($current), $after); $reset = []; foreach ($diff as $row) { if ($row['action'] === 'reset') { $reset[] = '„' . $row['label'] . '“'; } } $warnings = $diff === [] ? [] : [ThemeOptionsArea::SITE_WIDE]; if ($reset !== []) { $warnings[] = sprintf(self::RESET, implode(', ', $reset)); } return new DesignPlan( $after->data(), $diff, sprintf('Snapshot %s wiederherstellen – %s', $snapshotId, ThemeOptionDiff::summary($diff)), null, $warnings, true ); } } ``` - [ ] **Step 6a.4: GREEN-Lauf** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter ThemeOptionsRestorePlannerTest` Expected: `OK (4 tests, 20 assertions)`. - [ ] **Step 6a.5: Lint und Commit** Run: `composer lint` – Expected: keine Meldung. ```bash git add src/Integrations/Divi/Design/ThemeOptionsRestorePlanner.php \ tests/Unit/Integrations/Divi/Design/ThemeOptionsRestorePlannerTest.php git commit -m "feat: ThemeOptionsRestorePlanner – Snapshot theme_options ersetzt die Whitelist, nennt zurückgesetzte Optionen" \ -m "Co-Authored-By: Claude " ``` #### Task 6b: `lit/divi-design-restore` kennt `theme_options` - [ ] **Step 6b.1: `DiviDesignRestoreAbilityTest` anpassen** In `tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php`: 1. Importe ergänzen (nach `use LIT\MCP\Integrations\Divi\Design\SnapshotStore;`): ```php use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook; use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter; use LIT\MCP\Integrations\Divi\Design\ThemeOptionsRestorePlanner; ``` 2. `testUnknownSnapshotAndThemeOptions()` ersetzen durch die zwei Tests: ```php public function testUnknownSnapshots(): void { $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->allows('get')->andReturn(null); $ability = self::allAreas($snapshots, self::colorStore([]), self::variableStore([])); self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-abcdef']), 'not_found'); self::assertToolError($ability->execute(['snapshot_id' => 'colors-20261016T100000Z-abcdef']), 'not_found'); self::assertToolError($ability->execute(['snapshot_id' => 'variables-20261016T100000Z-abcdef']), 'not_found'); self::assertToolError( $ability->execute(['snapshot_id' => 'theme_options-20261016T100000Z-abcdef']), 'not_found' ); self::assertToolError($ability->execute(['snapshot_id' => 'kaputt']), 'validation_failed'); } /** A snapshot area whose planner is not wired stays unsupported (W11) – never a silent no-op. */ public function testAnAreaWithoutAPlannerIsUnsupported(): void { Functions\when('current_user_can')->justReturn(true); $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('canUseVisualBuilder')->andReturn(true); $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->shouldNotReceive('get'); $ability = new DiviDesignRestoreAbility( new DesignChange($snapshots, FakeChangeLog::forbidden()), $snapshots, new PresetRestorePlanner($writer) ); $error = self::assertToolError( $ability->execute(['snapshot_id' => ThemeOptionsRestorePlannerTest::SNAPSHOT_ID]), 'unsupported' ); self::assertStringNotContainsString('4d', $error['hint']); } ``` 3. `allAreas()` bekommt den Theme-Options-Planer – die ganze Methode samt neuer Hilfe `optionStore()` ersetzen: ```php /** The ability with the planners of all four areas; the user has every right unless a store says otherwise. */ private static function allAreas( SnapshotStore $snapshots, GlobalColorsAdapter $colors, GlobalVariablesAdapter $variables, ?FakeChangeLog $log = null, ?ThemeOptionsAdapter $options = null ): DiviDesignRestoreAbility { Functions\when('current_user_can')->justReturn(true); $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('canUseVisualBuilder')->andReturn(true); $writer->allows('presets')->andReturn(PresetBookTest::stored()); $writer->shouldNotReceive('savePresets'); $noOptions = []; return new DiviDesignRestoreAbility( new DesignChange($snapshots, $log ?? FakeChangeLog::forbidden()), $snapshots, new PresetRestorePlanner($writer), new ColorRestorePlanner($writer, $colors), new VariableRestorePlanner($writer, $variables), new ThemeOptionsRestorePlanner($writer, $options ?? self::optionStore($noOptions)) ); } /** * A theme options adapter that reads what it last wrote, starting with $stored (ThemeOptionsAreaTest::adapter()). * * @param array $stored */ private static function optionStore(array &$stored, bool $customize = true): ThemeOptionsAdapter { $writes = []; return ThemeOptionsAreaTest::adapter($stored, $writes, null, $customize); } ``` (Der Docblock nennt jetzt „all four areas“.) 4. Vor `testRightsFollowTheSnapshotArea()` den Restore-Test einfügen und den Rechte-Test ersetzen: ```php public function testRestoresThemeOptionsAndResetsTheKeysTheSnapshotLacks(): void { $id = ThemeOptionsRestorePlannerTest::SNAPSHOT_ID; $stored = ThemeOptionsRestorePlannerTest::current(); $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->allows('get')->with('theme_options', $id) ->andReturn(self::snapshotIn('theme_options', $id, ThemeOptionsRestorePlannerTest::snapshot())); $snapshots->expects('save') ->with( 'theme_options', ThemeOptionBook::fromDivi(ThemeOptionsRestorePlannerTest::current())->data(), 'lit/divi-design-restore' ) ->andReturn('theme_options-5'); $snapshots->expects('attachLog'); $ability = self::allAreas( $snapshots, self::colorStore([]), self::variableStore([]), new FakeChangeLog(8), self::optionStore($stored) ); $dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required'); self::assertSame(['added', 'changed', 'reset'], array_column($dry['diff'], 'action')); self::assertStringContainsString('Divi-Standard', $dry['warnings'][1]); $result = $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]); self::assertSame('theme_options-5', $result['snapshot_id']); self::assertSame( ThemeOptionBook::fromDivi(ThemeOptionsRestorePlannerTest::snapshot())->data(), ThemeOptionBook::fromDivi($stored)->data() ); } /** Spec §10, V10: the rights are those of the snapshot's area. */ public function testRightsFollowTheSnapshotArea(): void { $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->shouldNotReceive('get'); $options = []; $ability = self::allAreas( $snapshots, self::colorStore([]), self::variableStore([], false), null, self::optionStore($options, false) ); self::assertToolError( $ability->permission(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]), 'forbidden' ); self::assertToolError( $ability->execute(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]), 'forbidden' ); self::assertToolError( $ability->permission(['snapshot_id' => ThemeOptionsRestorePlannerTest::SNAPSHOT_ID]), 'forbidden' ); self::assertTrue($ability->permission(['snapshot_id' => ColorRestorePlannerTest::SNAPSHOT_ID])); self::assertTrue($ability->permission(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa'])); self::assertToolError($ability->permission(['snapshot_id' => 'kaputt']), 'forbidden', 'fail closed'); } ``` 5. `testFeaturesFollowTheSnapshotArea()` ersetzen: ```php public function testFeaturesFollowTheSnapshotArea(): void { $ability = self::allAreas(Mockery::mock(SnapshotStore::class), self::colorStore([]), self::variableStore([])); self::assertSame( [DiviFeature::DesignRead, DiviFeature::ColorWrite], $ability->diviFeatures(['snapshot_id' => ColorRestorePlannerTest::SNAPSHOT_ID]) ); self::assertSame( [DiviFeature::DesignRead, DiviFeature::VariableWrite], $ability->diviFeatures(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]) ); self::assertSame( [DiviFeature::DesignRead, DiviFeature::PresetWrite], $ability->diviFeatures(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa']) ); self::assertSame( [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $ability->diviFeatures(['snapshot_id' => ThemeOptionsRestorePlannerTest::SNAPSHOT_ID]) ); self::assertSame( [DiviFeature::DesignRead, DiviFeature::PresetWrite, DiviFeature::ColorWrite, DiviFeature::VariableWrite, DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $ability->diviFeatures([]) ); } ``` - [ ] **Step 6b.2: RED-Lauf** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviDesignRestoreAbilityTest` Expected: `Tests: 10, Assertions: 116, Failures: 1.` – nur `testAnAreaWithoutAPlannerIsUnsupported` mit `Failed asserting that 'Theme-Optionen folgen mit Etappe 4d.' does not contain "4d".` Der Restore über den neuen Planer ist schon grün: die Ability ist planer-generisch (4c), sie braucht nur die neuen Texte. - [ ] **Step 6b.3: `DiviDesignRestoreAbility` anpassen** In `src/Integrations/Divi/Design/DiviDesignRestoreAbility.php`: 1. Docblock der Klasse, letzter Satz: ```php * the plan. Areas without a planner (theme_options until Etappe 4d) are not restorable yet. ``` ersetzen durch ```php * the plan. A snapshot of an area without a planner is refused as unsupported, never treated as a no-op. ``` 2. In `description()` das Ende ab `Bereiche:` ersetzen: ```php . 'beim Nutzer confirm: true; vorher wird der aktuelle Stand selbst gesichert. Bereiche: presets, colors ' . '(Farben, die es im Snapshot nicht gibt, entfallen), variables (Variablen, die erst nach dem Snapshot ' . 'entstanden, bleiben archiviert erhalten) und theme_options (Theme-Optionen, die im Snapshot nicht ' . 'gesetzt sind, kehren zum Divi-Standard zurück).'; ``` 3. Der `unsupported`-Zweig in `execute()`: ```php return Errors::unsupported( 'Snapshots dieses Bereichs lassen sich hier nicht wiederherstellen.', 'Den Stand des Snapshots im Divi-Builder bzw. -Customizer von Hand herstellen.' ); ``` - [ ] **Step 6b.4: GREEN-Lauf** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'DiviDesignRestoreAbilityTest|ThemeOptionsRestorePlannerTest'` Expected: `OK (14 tests, 137 assertions)`. - [ ] **Step 6b.5: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` Expected: grün, Stand nach Task 5 + 6 Tests (Probe: 3162), Lint ohne Meldung. ```bash git add src/Integrations/Divi/Design/DiviDesignRestoreAbility.php \ tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php git commit -m "feat: lit/divi-design-restore stellt theme_options wieder her; Hinweis auf Etappe 4d entfällt" \ -m "Co-Authored-By: Claude " ``` ### Task 7: `RemoteFileFetcher` aus `MediaImporter` herauslösen (Refactor, Verhalten gleich) Der URL-Teil von `MediaImporter` (Schema-, Core- und eigene Bereichsprüfung, IPv4-Pin, Weiterleitungs-Hook, Streamen in eine Temp-Datei mit Abschneiden bei Limit + 1 Byte, Zeitlimit 30 s, Protokoll ohne Zugangsdaten/Query, keine Fehlerursache für den Client – Security-Audit M3) wandert unverändert in die neue Klasse `RemoteFileFetcher`, damit `lit/divi-fonts-upload` (Task 8) dieselbe Härtung nutzt. Medienbezogen bleiben in `MediaImporter`: die Fehlermeldungen an den Client (`validation_failed` mit `url`), die `Content-Length`-Prüfung gegen `Limits::uploadBytes()`, Typprüfung, Sideload, Alt-Text. Der Konstruktor `MediaImporter(Limits, ?Closure $resolve)` bleibt gleich – `MediaIntegration` und alle Tests bleiben unverändert. Sicherheitsnetz: `MediaImporterTest` (deckt die Wächter über `fromUrl()` ab), `MediaUploadAbilitiesTest`, `PublicAddressTest` – sie werden **nicht** geändert. `RemoteFileFetcherTest` prüft nur, was ein zweiter Aufrufer braucht (Limit als Parameter, `null` statt Fehlermeldung, Protokoll-Kontext, Hooks nur während des Requests). **Files:** - Create: `src/Integrations/Media/RemoteFileFetcher.php` - Modify: `src/Integrations/Media/MediaImporter.php` - Test: `tests/Unit/Integrations/Media/RemoteFileFetcherTest.php` (neu); `MediaImporterTest.php`, `MediaUploadAbilitiesTest.php`, `PublicAddressTest.php` unverändert **Interfaces:** - Consumes: `PublicAddress::hostIsPublic()`/`dnsResolver()`, `Log::failure()`, WordPress `wp_parse_url`, `wp_http_validate_url`, `wp_safe_remote_get`, `wp_remote_retrieve_response_code`, `add_action`/`remove_action` (Hooks `http_api_curl`, `requests-requests.before_redirect`). - Produces (Task 8 nutzt, Task 9 verdrahtet mit `new RemoteFileFetcher()`): ```php // src/Integrations/Media/RemoteFileFetcher.php – class (nicht final, für Fakes) public function __construct(?Closure $resolve = null); // DNS-Lookup; null = PublicAddress::dnsResolver() public function allows(string $url): bool; // http(s) + wp_http_validate_url + nur öffentliche Adressen /** @return array|null Antwort (Header, Status); null = Download fehlgeschlagen (Grund nur im Log) */ public function fetch(string $url, string $tmp, int $maxBytes, string $context): ?array; // limit_response_size = $maxBytes + 1 ``` - Unverändert: `MediaImporter::__construct(Limits $limits, ?Closure $resolve = null)`, `fromUrl()`, `fromBase64()`. Keine Verdrahtungsänderung nötig. - [ ] **Step 1: Sicherheitsnetz grün** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'MediaImporterTest|MediaUploadAbilitiesTest|PublicAddressTest'` Expected: `OK (71 tests, 246 assertions)` - [ ] **Step 2: Failing Test für `RemoteFileFetcher` schreiben** `tests/Unit/Integrations/Media/RemoteFileFetcherTest.php`: ```php tmp = (string) tempnam(sys_get_temp_dir(), 'lit-fetch'); $this->errorLog = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); $this->previousErrorLog = ini_set('error_log', $this->errorLog); Functions\when('wp_parse_url')->alias('parse_url'); Functions\when('wp_http_validate_url')->returnArg(); Functions\when('wp_remote_retrieve_response_code')->alias(static fn (array $r): int => $r['response']['code']); } protected function tearDown(): void { ini_set('error_log', (string) $this->previousErrorLog); foreach ([$this->tmp, $this->errorLog] as $file) { if (is_file($file)) { unlink($file); } } parent::tearDown(); } /** @param list $addresses What every host name resolves to (no DNS in unit tests). */ private function fetcher(array $addresses = ['93.184.216.34']): RemoteFileFetcher { return new RemoteFileFetcher(static fn (string $host): array => $addresses); } public function testOnlyPublicHttpUrlsAreAllowed(): void { self::assertTrue($this->fetcher()->allows('https://fonts.example.com/inter.woff2')); self::assertTrue($this->fetcher()->allows('HTTP://fonts.example.com/inter.woff2')); self::assertFalse($this->fetcher()->allows('ftp://fonts.example.com/inter.woff2')); self::assertFalse($this->fetcher()->allows('file:///etc/passwd')); self::assertFalse($this->fetcher()->allows('http://169.254.169.254/latest/meta-data/')); self::assertFalse($this->fetcher(['10.0.0.5'])->allows('https://intern.example.com/x.woff2')); self::assertFalse($this->fetcher([])->allows('https://nirgends.example.com/x.woff2')); Functions\when('wp_http_validate_url')->justReturn(false); self::assertFalse($this->fetcher()->allows('https://fonts.example.com/inter.woff2')); } public function testTheBodyIsStreamedIntoTheTempFileAndCutOffOneByteAboveTheLimit(): void { Functions\expect('wp_safe_remote_get')->once()->with( 'https://fonts.example.com/inter.woff2', Mockery::on(function (array $args): bool { file_put_contents($args['filename'], 'wOF2DATA'); return $args['filename'] === $this->tmp && $args['stream'] === true && $args['timeout'] === 30 && $args['limit_response_size'] === 2048 + 1; }) )->andReturn(['response' => ['code' => 200], 'headers' => ['content-type' => 'font/woff2']]); $response = $this->fetcher()->fetch('https://fonts.example.com/inter.woff2', $this->tmp, 2048, 'font upload'); self::assertSame(['code' => 200], $response['response'] ?? null); self::assertSame('wOF2DATA', file_get_contents($this->tmp)); } public function testAFailedRequestIsNullAndOnlyTheLogNamesTheReasonAndTheContext(): void { Functions\when('wp_safe_remote_get')->justReturn(new WP_Error('http_request_failed', 'Connection refused')); $response = $this->fetcher()->fetch( 'https://nutzer:geheim@fonts.example.com:8443/inter.woff2?token=abc', $this->tmp, 2048, 'font upload' ); $log = (string) file_get_contents($this->errorLog); self::assertNull($response); self::assertStringContainsString('font upload from https://fonts.example.com:8443/inter.woff2', $log); self::assertStringContainsString('Connection refused', $log); self::assertStringNotContainsString('geheim', $log); self::assertStringNotContainsString('token=abc', $log); } public function testAnHttpErrorStatusIsNull(): void { Functions\when('wp_safe_remote_get')->justReturn(['response' => ['code' => 404], 'headers' => []]); self::assertNull($this->fetcher()->fetch('https://fonts.example.com/fehlt.woff2', $this->tmp, 2048, 'x')); self::assertStringContainsString('HTTP status 404', (string) file_get_contents($this->errorLog)); } public function testTheIpv4AndRedirectHooksLiveOnlyDuringTheRequest(): void { $listener = null; Actions\expectAdded('http_api_curl')->once(); Actions\expectAdded('requests-requests.before_redirect')->once() ->whenHappen(static function (callable $callback) use (&$listener): void { $listener = $callback; }); Functions\when('wp_safe_remote_get')->justReturn(['response' => ['code' => 200], 'headers' => []]); $this->fetcher()->fetch('https://fonts.example.com/inter.woff2', $this->tmp, 2048, 'x'); self::assertFalse(has_action('http_api_curl')); self::assertFalse(has_action('requests-requests.before_redirect')); self::assertIsCallable($listener); $listener('https://cdn.example.com/inter.woff2'); // public: no exception $this->expectException(\WpOrg\Requests\Exception::class); $listener('http://127.0.0.1/inter.woff2'); } } ``` - [ ] **Step 3: RED** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter RemoteFileFetcherTest` Expected: `Tests: 5, Assertions: 0, Errors: 5.` – jeweils `Error: Class "LIT\MCP\Integrations\Media\RemoteFileFetcher" not found`. - [ ] **Step 4: `RemoteFileFetcher` anlegen (Code aus `MediaImporter::download()`, `isPublicUrl()`, `loggableUrl()`, `downloadFailed()` – unverändert bis auf `$maxBytes`, `$context` und `null`)** `src/Integrations/Media/RemoteFileFetcher.php`: ```php Host name => IPv4 addresses. */ private Closure $resolve; /** @param (Closure(string): list)|null $resolve DNS lookup; null = the real one. */ public function __construct(?Closure $resolve = null) { $this->resolve = $resolve ?? PublicAddress::dnsResolver(); } /** Whether $url may be fetched at all: http(s), accepted by core, host with public addresses only. */ public function allows(string $url): bool { $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)); return in_array($scheme, ['http', 'https'], true) && wp_http_validate_url($url) && $this->isPublicUrl($url); } /** * Streams $url into $tmp; the caller checks allows() first and the size of $tmp afterwards (a body above * $maxBytes is cut off at $maxBytes + 1 bytes). * * @param string $context Names the download in the failure log, e.g. "media import". * @return array|null The response (headers, status), null when the download failed. */ public function fetch(string $url, string $tmp, int $maxBytes, string $context): ?array { // IPv4 only, for this request only: wp_http_validate_url() checks the IPv4 address // (gethostbyname), so an AAAA record must not lead cURL to an unchecked IPv6 target. $ipv4 = static function ($handle): void { if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) { // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- http_api_curl hands us the handle. curl_setopt($handle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); } }; // Every redirect target gets the plugin's own range check too; Requests turns the // exception into a failed request (WP_Http returns a WP_Error). $redirect = function ($location): void { if (! $this->isPublicUrl((string) $location)) { throw new RequestsException('Redirect to a non-public address refused', 'lit_mcp_redirect'); } }; add_action('http_api_curl', $ipv4); add_action(self::REDIRECT_HOOK, $redirect); try { $response = wp_safe_remote_get($url, [ 'timeout' => self::TIMEOUT, 'stream' => true, 'filename' => $tmp, 'limit_response_size' => $maxBytes + 1, ]); } finally { remove_action('http_api_curl', $ipv4); remove_action(self::REDIRECT_HOOK, $redirect); } if ($response instanceof WP_Error) { return $this->failed($context, $url, $response->get_error_message()); } $status = (int) wp_remote_retrieve_response_code($response); if ($status !== 200) { return $this->failed($context, $url, sprintf('HTTP status %d', $status)); } return $response; } /** Host (name or IP literal) of $url resolves to public addresses only (PublicAddress). */ private function isPublicUrl(string $url): bool { return PublicAddress::hostIsPublic((string) wp_parse_url($url, PHP_URL_HOST), $this->resolve); } /** The reason goes to the log only (security audit M3). */ private function failed(string $context, string $url, string $reason): ?array { Log::failure($context . ' from ' . self::loggableUrl($url), $reason); return null; } /** Scheme, host, port and path of $url – no credentials, query string or fragment in the log. */ private static function loggableUrl(string $url): string { $parts = wp_parse_url($url); if (! is_array($parts)) { return '(ungültige URL)'; } $port = isset($parts['port']) ? ':' . (int) $parts['port'] : ''; return sprintf('%s://%s%s%s', $parts['scheme'] ?? '', $parts['host'] ?? '', $port, $parts['path'] ?? ''); } } ``` - [ ] **Step 5: GREEN** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter RemoteFileFetcherTest` Expected: `OK (5 tests, 24 assertions)` - [ ] **Step 6: `MediaImporter` auf `RemoteFileFetcher` umstellen** ```diff --- a/src/Integrations/Media/MediaImporter.php +++ b/src/Integrations/Media/MediaImporter.php @@ -11,18 +11,15 @@ use LIT\MCP\Limits; use LIT\MCP\Log; use Throwable; use WP_Error; -use WpOrg\Requests\Exception as RequestsException; defined('ABSPATH') || exit; /** * Imports files into the media library (spec §6.3, §10). * - * - URL: only http(s), checked with wp_http_validate_url() and the plugin's own range check - * (PublicAddress, independent of the core version) before any request and on every redirect, - * fetched with wp_safe_remote_get() (core re-validates redirects too), streamed into a temp file - * and cut off one byte above the upload limit. Network failures reach the client only as one - * generic message (no refused/timeout/status oracle, security audit M3); details go to the log. + * - URL: downloaded by RemoteFileFetcher (public http(s) only, redirects checked, streamed into a + * temp file and cut off one byte above the upload limit). Network failures reach the client only + * as one generic message (no refused/timeout/status oracle, security audit M3); details go to the log. * - Base64: decoded strictly; the size is checked against Limits::inlineBytes() (upload limit, * at most 10 MB – the whole file sits in memory) before anything is decoded or written. * - Type: whitelist (filter lit_mcp_upload_mimes) intersected with what WordPress allows the @@ -48,28 +45,20 @@ class MediaImporter 'mp3' => 'audio/mpeg', ]; - private const TIMEOUT = 30; private const ALT_KEY = '_wp_attachment_image_alt'; - private const REDIRECT_HOOK = 'requests-requests.before_redirect'; - /** @var Closure(string): list Host name => IPv4 addresses. */ - private Closure $resolve; + private RemoteFileFetcher $fetcher; /** @param (Closure(string): list)|null $resolve DNS lookup; null = the real one. */ public function __construct(private Limits $limits, ?Closure $resolve = null) { - $this->resolve = $resolve ?? PublicAddress::dnsResolver(); + $this->fetcher = new RemoteFileFetcher($resolve); } /** @param array{title?: string, alt?: string, post_id?: int} $meta */ public function fromUrl(string $url, ?string $filename, array $meta): int|WP_Error { - $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)); - if ( - ! in_array($scheme, ['http', 'https'], true) - || ! wp_http_validate_url($url) - || ! $this->isPublicUrl($url) - ) { + if (! $this->fetcher->allows($url)) { return Errors::invalidInput( 'Die URL ist nicht erlaubt.', ['url' => 'nur öffentlich erreichbare http(s)-Adressen'], @@ -134,42 +123,9 @@ class MediaImporter /** @return array|WP_Error The response (body streamed into $tmp). */ private function download(string $url, string $tmp): array|WP_Error { - // IPv4 only, for this request only: wp_http_validate_url() checks the IPv4 address - // (gethostbyname), so an AAAA record must not lead cURL to an unchecked IPv6 target. - $ipv4 = static function ($handle): void { - if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) { - // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- http_api_curl hands us the handle. - curl_setopt($handle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); - } - }; - // Every redirect target gets the plugin's own range check too; Requests turns the - // exception into a failed request (WP_Http returns a WP_Error). - $redirect = function ($location): void { - if (! $this->isPublicUrl((string) $location)) { - throw new RequestsException('Redirect to a non-public address refused', 'lit_mcp_redirect'); - } - }; - add_action('http_api_curl', $ipv4); - add_action(self::REDIRECT_HOOK, $redirect); - try { - $response = wp_safe_remote_get($url, [ - 'timeout' => self::TIMEOUT, - 'stream' => true, - 'filename' => $tmp, - 'limit_response_size' => $this->limits->uploadBytes() + 1, - ]); - } finally { - remove_action('http_api_curl', $ipv4); - remove_action(self::REDIRECT_HOOK, $redirect); - } - - if ($response instanceof WP_Error) { - return $this->downloadFailed($url, $response->get_error_message()); - } - - $status = (int) wp_remote_retrieve_response_code($response); - if ($status !== 200) { - return $this->downloadFailed($url, sprintf('HTTP status %d', $status)); + $response = $this->fetcher->fetch($url, $tmp, $this->limits->uploadBytes(), 'media import'); + if ($response === null) { + return $this->downloadFailed(); } $length = (string) wp_remote_retrieve_header($response, 'content-length'); @@ -361,30 +317,9 @@ class MediaImporter } } - /** Host (name or IP literal) of $url resolves to public addresses only (PublicAddress). */ - private function isPublicUrl(string $url): bool + /** One message for every network failure (security audit M3); RemoteFileFetcher logged the reason. */ + private function downloadFailed(): WP_Error { - return PublicAddress::hostIsPublic((string) wp_parse_url($url, PHP_URL_HOST), $this->resolve); - } - - /** Scheme, host, port and path of $url – no credentials, query string or fragment in the log. */ - private static function loggableUrl(string $url): string - { - $parts = wp_parse_url($url); - if (! is_array($parts)) { - return '(ungültige URL)'; - } - - $port = isset($parts['port']) ? ':' . (int) $parts['port'] : ''; - - return sprintf('%s://%s%s%s', $parts['scheme'] ?? '', $parts['host'] ?? '', $port, $parts['path'] ?? ''); - } - - /** One message for every network failure (security audit M3); the reason is logged only. */ - private function downloadFailed(string $url, string $reason): WP_Error - { - Log::failure('media import from ' . self::loggableUrl($url), $reason); - return Errors::invalidInput( 'Die Datei konnte nicht geladen werden.', ['url' => 'nicht abrufbar'], ``` (`Log` bleibt importiert – der Sideload-Fehler protokolliert weiter selbst; `PublicAddress` wird in `MediaImporter` nicht mehr gebraucht, es steht im selben Namespace und hatte kein `use`.) - [ ] **Step 7: Sicherheitsnetz weiter grün, Tests unverändert** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'MediaImporterTest|MediaUploadAbilitiesTest|PublicAddressTest|RemoteFileFetcherTest'` Expected: `OK (76 tests, 270 assertions)` (71 + 5). Run: `git diff --stat tests/Unit/Integrations/Media/MediaImporterTest.php tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php src/Integrations/Media/MediaIntegration.php` Expected: keine Ausgabe. - [ ] **Step 8: Gesamtlauf, Lint, Commit** Run: `composer test && composer lint` Expected: OK (Basis + 5 Tests), Lint sauber. ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git add src/Integrations/Media/RemoteFileFetcher.php src/Integrations/Media/MediaImporter.php \ tests/Unit/Integrations/Media/RemoteFileFetcherTest.php git commit -m "refactor: RemoteFileFetcher aus MediaImporter herausgelöst (URL-Prüfung, Weiterleitungen, Download)" -m "Co-Authored-By: Claude " ``` --- ### Task 8: `FontSignature`, `FontNameRules`, `FontInstaller`, `lit/divi-fonts-upload` Schriften hochladen nach W8–W10, in drei Blöcken mit je eigenem Commit: 8a reine Regeln (Magic Bytes, Namen), 8b Divi-Adapter `FontInstaller` mit Manifest, Mirror `font-install` und Fingerabdrücken, 8c die Ability. Ablauf der Ability: Rechte (`DesignRights::fonts()`) in `permission()` und `execute()` → alle Eingabefehler gesammelt (`validation_failed` mit Feldpfaden, auch unbekannte Schlüssel oben und unter `files`) → Name belegt: `conflict` → Download jeder Datei über `RemoteFileFetcher` (je höchstens `min(Limits::uploadBytes(), 5 MB)`), Größe, `FontSignature` passend zum Schlüssel, `FontInstaller::accepts()` → ohne `confirm` `confirm_required` mit Name, Dateien (`format`, `bytes`, `sha256`), Gewichten, Familie und Hinweis „ganze Website“; mit `confirm: true` erneuter Download + Prüfung, `FontInstaller::install()`, Teilerfolg → `FontInstaller::remove()` (`WriteFailed::rolledBack`, sonst `partial` + `recordPartial`), Erfolg → `ChangeLog::record('lit/divi-fonts-upload', 'divi_font', , …SHA-256…)`. Temp-Dateien werden auf jedem Weg gelöscht (`try/finally`). Neue Fehlercodes (Spec §11): `font_fetch_failed` (400, mindestens eine Datei nicht abrufbar – ohne Ursache, M3) und `font_invalid` (400, Größe/Inhalt/Divi-Prüfung), beide mit `fields`; `conflict` (409) wie in `DesignChange`. **Voraussetzung aus Task 1–2 (Schnittstellenvertrag):** `DiviFeature::FontUpload` (`font-upload`, schreibend), `FontCatalog::reserved()`/`canManage()`, `DesignRights::fonts(FontCatalog): ?WP_Error`. Die Tests ersetzen `FontCatalog` per Mockery und hängen nur an `reserved()`/`canManage()`; die Rechte-Tests verlangen nur `forbidden` (Texte gehören Task 2). **Files:** - Create: `src/Integrations/Divi/Design/FontSignature.php`, `FontNameRules.php`, `FontInstaller.php`, `DiviFontsUploadAbility.php`; `tests/Fixtures/divi/reference/font-install.txt` (generiert) - Modify: `src/Integrations/Divi/Release/DiviAdapters.php`, `src/Integrations/Divi/Release/fingerprints.json` (generiert) - Test: `tests/Unit/Integrations/Divi/Design/FontSignatureTest.php`, `FontNameRulesTest.php`, `FontInstallerTest.php`, `DiviFontsUploadAbilityTest.php` (neu); `tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php`, `tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php` (geändert) **Interfaces:** - Consumes: `DiviFeature::FontUpload`, `FontCatalog` (`reserved()`, `canManage()`), `DesignRights::fonts()` (Task 1–2); `RemoteFileFetcher` (Task 7); `Limits::uploadBytes()`, `ChangeLog::record()`/`recordPartial()`, `Rollback::attempt()`, `WriteFailed::rolledBack()`/`partial()`, `Errors::make()`/`invalidInput()`/`confirmRequired()`/`writeFailed()`, `ToolResult`, `Annotations::write(false)`, `AdminIncludes::load()` (lädt `wp-admin/includes/file.php` für `wp_tempnam` und `wp_handle_upload`), `ReleaseGate::assertFeature()`. - Divi (nur `FontInstaller`): `FontsUtility::mime_types_font` (0), `HooksRegistration::check_filetype_and_ext_font` (3), `FontsUtility::font_add` (4), `FontsUtility::font_remove` (1), Konstante `FontsUtility::FONT_NAME_MAX_LENGTH`, Mirror `font-install` (`AiAgentImageUtility::install_to_custom_fonts`); alle mit `font-upload`. WordPress (kein Manifest): `sanitize_file_name`, `is_wp_error`, `wp_tempnam`, `wp_delete_file`, `wp_handle_upload` (in `font_add`). - Produces (Task 9 verdrahtet): ```php // FontSignature.php – reines PHP public const HEADER_BYTES = 4; public static function detect(string $header): ?string; // 'woff2'|'woff'|'ttf'|'otf'|null // FontNameRules.php – reines PHP public const MAX_LENGTH = 100; public static function check(string $name, array $reserved, int $maxLength = self::MAX_LENGTH): ?string; public static function taken(string $name, array $reserved): ?string; // vorhandener Name (ohne Groß-/Kleinschreibung) oder null // FontInstaller.php – class (nicht final) implements DiviAdapter, Manifest::DESIGN, features [FontUpload] public const FORMATS = ['woff2', 'woff', 'ttf', 'otf']; public function __construct(); // keine Abhängigkeiten public function formats(): array; public function nameMaxLength(): int; public function accepts(string $path, string $filename): bool; public function install(string $name, array $files, string $weights, string $family): array; // gespeicherte Endungen; @throws DiviLocked, RuntimeException (dann ist nichts gespeichert) public function remove(string $name): void; // @throws RuntimeException // DiviFontsUploadAbility.php – final, implements Ability, RequiresDiviFeatures; lit/divi-fonts-upload public function __construct(FontCatalog $catalog, FontInstaller $installer, RemoteFileFetcher $fetcher, ChangeLog $log, Limits $limits); public const MAX_FILE_BYTES = 5 * 1024 * 1024; public const OBJECT_TYPE = 'divi_font'; // diviFeatures() = [FontUpload]; isDirect() = false; annotations() = Annotations::write(false); Gruppe divi (Task 9) ``` Verdrahtung in Task 9: `new DiviFontsUploadAbility($fontCatalog, new FontInstaller(), new RemoteFileFetcher(), $this->log, new Limits())` (`Limits` liest die Option bei jedem Aufruf; `DiviIntegration` hat bisher keine Instanz). Tabellenzeile für `DiviToolFeaturesTest`: `'lit/divi-fonts-upload' => ['font-upload']`, Schreib-Tool. - Keine Konstruktoränderung an bestehenden Klassen → keine Verdrahtungsänderung in diesem Task. #### 8a – `FontSignature` und `FontNameRules` - [ ] **Step 1: Failing Tests schreiben** `tests/Unit/Integrations/Divi/Design/FontSignatureTest.php`: ```php 'woff2', 'wOFF' => 'woff', "\x00\x01\x00\x00" => 'ttf', 'true' => 'ttf', 'OTTO' => 'otf', ]; /** @return 'woff2'|'woff'|'ttf'|'otf'|null */ public static function detect(string $header): ?string { return self::MAGIC[substr($header, 0, self::HEADER_BYTES)] ?? null; } } ``` - [ ] **Step 4: `FontNameRules` anlegen** `src/Integrations/Divi/Design/FontNameRules.php`: ```php $reserved Names of Google, web-safe and uploaded fonts (FontCatalog::reserved()). * @return string|null null = valid, else the problem (German). */ public static function check(string $name, array $reserved, int $maxLength = self::MAX_LENGTH): ?string { if (trim($name) === '') { return sprintf('Name fehlt (1–%d Zeichen)', $maxLength); } if (preg_match(self::ALLOWED, $name) !== 1) { $stored = trim((string) preg_replace(self::DIVI_STRIPS, '', $name)); return 'nur Buchstaben A–Z/a–z, Ziffern, Leerzeichen, _ und - erlaubt (' . ($stored === '' ? 'Divi ließe nichts übrig)' : sprintf('Divi würde „%s“ speichern)', $stored)); } if ($name !== trim($name)) { return 'ohne Leerzeichen am Anfang oder Ende'; } if (strlen($name) > $maxLength) { return sprintf('höchstens %d Zeichen (%d angegeben)', $maxLength, strlen($name)); } $taken = self::taken($name, $reserved); return $taken === null ? null : sprintf( '„%s“ gibt es schon als Google-, Web-Safe- oder hochgeladene Schrift (ohne Beachtung der ' . 'Groß-/Kleinschreibung)', $taken ); } /** * @param list $reserved * @return string|null The existing name $name collides with (case-insensitive), null when it is free. */ public static function taken(string $name, array $reserved): ?string { $lower = strtolower($name); foreach ($reserved as $existing) { if (strtolower($existing) === $lower) { return $existing; } } return null; } } ``` - [ ] **Step 5: GREEN, Lint, Commit** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'FontSignatureTest|FontNameRulesTest'` Expected: `OK (7 tests, 33 assertions)` Run: `composer lint` Expected: sauber. ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git add src/Integrations/Divi/Design/FontSignature.php src/Integrations/Divi/Design/FontNameRules.php \ tests/Unit/Integrations/Divi/Design/FontSignatureTest.php tests/Unit/Integrations/Divi/Design/FontNameRulesTest.php git commit -m "feat: FontSignature (Magic Bytes) und FontNameRules (Namensregeln eigener Schriften)" -m "Co-Authored-By: Claude " ``` #### 8b – `FontInstaller` (Divi-Adapter, Manifest, Mirror `font-install`, Fingerabdrücke) - [ ] **Step 6: Failing Test schreiben** `tests/Unit/Integrations/Divi/Design/FontInstallerTest.php` (Vorbild `GlobalColorsAdapterTest`; die Divi-Klassen per `Mockery::mock('alias:…')` in eigenem Prozess, die Konstante über `Mockery::getConfiguration()->setConstantsMap()`): ```php false, 'type' => false, 'proper_filename' => false]; /** @var list */ private array $files = []; protected function setUp(): void { parent::setUp(); Functions\when('sanitize_file_name')->alias(static fn (string $name): string => str_replace(' ', '-', $name)); } protected function tearDown(): void { foreach ($this->files as $file) { if (is_file($file)) { unlink($file); } } parent::tearDown(); } private function tempFile(string $bytes): string { $file = (string) tempnam(sys_get_temp_dir(), 'lit-font'); file_put_contents($file, $bytes); $this->files[] = $file; return $file; } public function testIsARegisteredDesignAdapterForFontUpload(): void { $manifest = FontInstaller::manifest(); self::assertContains(FontInstaller::class, DiviAdapters::CLASSES); self::assertSame(Manifest::DESIGN, $manifest->area); self::assertSame( ['FontsUtility::mime_types_font()', 'HooksRegistration::check_filetype_and_ext_font()', 'FontsUtility::font_add()', 'FontsUtility::font_remove()'], array_map(static fn (Call $call): string => $call->label(), $manifest->calls) ); self::assertSame([0, 3, 4, 1], array_map(static fn (Call $call): ?int => $call->args, $manifest->calls)); self::assertSame( [self::FONTS . '::FONT_NAME_MAX_LENGTH'], array_map(static fn (ConstantRequirement $constant): string => $constant->name, $manifest->constants) ); self::assertCount(1, $manifest->mirrors); self::assertSame('font-install', $manifest->mirrors[0]->id); self::assertSame( 'includes/builder-5/server/VisualBuilder/AiAgent/AiAgentImageUtility.php', $manifest->mirrors[0]->file ); self::assertSame('AiAgentImageUtility::install_to_custom_fonts()', $manifest->mirrors[0]->label()); self::assertSame([DiviFeature::FontUpload], $manifest->features); } public function testInstallNeedsAReleasedDivi(): void { ReleaseGate::useResolver( static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0') ); $this->expectException(DiviLocked::class); (new FontInstaller())->install('Inter', ['woff2' => '/tmp/x'], 'all', 'sans-serif'); } /** @runInSeparateProcess */ public function testFormatsAreThePluginsFourAsFarAsDiviKnowsThem(): void { Mockery::mock('alias:' . self::FONTS)->allows('mime_types_font')->andReturn([ 'otf' => ['font/otf'], 'ttf' => ['font/ttf'], 'woff' => ['font/woff'], 'eot' => ['application/vnd.ms-fontobject'], ]); self::assertSame(['woff', 'ttf', 'otf'], (new FontInstaller())->formats(), 'no woff2 in Divi, never eot'); } /** @runInSeparateProcess */ public function testTheNameLimitIsDivis(): void { Mockery::getConfiguration()->setConstantsMap([self::FONTS => ['FONT_NAME_MAX_LENGTH' => 80]]); Mockery::mock('alias:' . self::FONTS); self::assertSame(80, (new FontInstaller())->nameMaxLength()); } /** @runInSeparateProcess */ public function testAcceptsAsksDivisContentCheckAndWantsTheGivenExtensionBack(): void { $file = $this->tempFile('wOF2DATA'); $hooks = Mockery::mock('alias:' . self::HOOKS); $hooks->expects('check_filetype_and_ext_font')->with(self::UNCHECKED, $file, 'Inter.woff2') ->andReturn(['ext' => 'woff2', 'type' => 'font/woff2', 'proper_filename' => 'Inter.woff2']); $hooks->expects('check_filetype_and_ext_font')->with(self::UNCHECKED, $file, 'Inter.ttf') ->andReturn(['ext' => 'woff2', 'type' => 'font/woff2', 'proper_filename' => 'Inter.ttf']); $hooks->expects('check_filetype_and_ext_font')->with(self::UNCHECKED, $file, 'Inter.otf') ->andReturn(self::UNCHECKED); $installer = new FontInstaller(); self::assertTrue($installer->accepts($file, 'Inter.woff2')); self::assertFalse($installer->accepts($file, 'Inter.ttf'), 'Divi reports another extension'); self::assertFalse($installer->accepts($file, 'Inter.otf'), 'Divi refuses the content'); } /** @runInSeparateProcess */ public function testInstallHandsTheTempFilesToFontAddLikeDivisAiAgent(): void { $woff2 = $this->tempFile('wOF2DATA'); $ttf = $this->tempFile("\x00\x01\x00\x00TTF"); $hooks = Mockery::mock('alias:' . self::HOOKS); $hooks->allows('check_filetype_and_ext_font')->andReturnUsing( static fn (array $unchecked, string $file, string $name): array => [ 'ext' => pathinfo($name, PATHINFO_EXTENSION), 'type' => 'font/' . pathinfo($name, PATHINFO_EXTENSION), 'proper_filename' => $name, ] ); Mockery::mock('alias:' . self::FONTS)->expects('font_add')->with( [ 'woff2' => ['name' => 'Inter-Display.woff2', 'type' => 'font/woff2', 'tmp_name' => $woff2, 'error' => 0, 'size' => 8], 'ttf' => ['name' => 'Inter-Display.ttf', 'type' => 'font/ttf', 'tmp_name' => $ttf, 'error' => 0, 'size' => 7], ], 'Inter Display', ['font_weights' => '400,700', 'generic_family' => 'serif'], ['action' => 'wp_handle_upload_rest'] )->andReturn([ 'uploaded_font' => 'Inter Display', 'updated_fonts' => [ 'Andere' => ['font_file' => ['otf' => '/up/et-fonts/Andere.otf']], 'Inter Display' => ['font_file' => [ 'woff2' => '/up/et-fonts/Inter-Display.woff2', 'ttf' => '/up/et-fonts/Inter-Display.ttf', ]], ], ]); $installed = (new FontInstaller())->install( 'Inter Display', ['woff2' => $woff2, 'ttf' => $ttf], '400,700', 'serif' ); self::assertSame(['woff2', 'ttf'], $installed); } /** @runInSeparateProcess */ public function testAPartialSuccessListsOnlyTheFormatsDiviStored(): void { $woff2 = $this->tempFile('wOF2DATA'); $ttf = $this->tempFile("\x00\x01\x00\x00TTF"); Mockery::mock('alias:' . self::HOOKS)->allows('check_filetype_and_ext_font')->andReturnUsing( static fn (array $unchecked, string $file, string $name): array => [ 'ext' => pathinfo($name, PATHINFO_EXTENSION), 'type' => 'font/x', 'proper_filename' => $name, ] ); Mockery::mock('alias:' . self::FONTS)->allows('font_add')->andReturn([ 'uploaded_font' => 'Inter', 'updated_fonts' => ['Inter' => ['font_file' => ['woff2' => '/up/et-fonts/Inter.woff2']]], ]); self::assertSame( ['woff2'], (new FontInstaller())->install('Inter', ['woff2' => $woff2, 'ttf' => $ttf], 'all', 'sans-serif') ); } /** @runInSeparateProcess */ public function testAFileDivisTypeCheckRefusesStopsTheInstallBeforeFontAdd(): void { $woff2 = $this->tempFile('wOF2DATA'); Mockery::mock('alias:' . self::HOOKS)->allows('check_filetype_and_ext_font')->andReturn(self::UNCHECKED); Mockery::mock('alias:' . self::FONTS)->expects('font_add')->never(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage("font file woff2 refused by Divi's type check"); (new FontInstaller())->install('Inter', ['woff2' => $woff2], 'all', 'sans-serif'); } /** @runInSeparateProcess */ public function testARefusedFontAddIsARuntimeException(): void { $woff2 = $this->tempFile('wOF2DATA'); Mockery::mock('alias:' . self::HOOKS)->allows('check_filetype_and_ext_font') ->andReturn(['ext' => 'woff2', 'type' => 'font/woff2', 'proper_filename' => 'Inter.woff2']); Mockery::mock('alias:' . self::FONTS)->allows('font_add') ->andReturn(new WP_Error('upload_error_handler', 'Sorry, you are not allowed to upload this file type.')); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('font_add refused: Sorry, you are not allowed to upload this file type.'); (new FontInstaller())->install('Inter', ['woff2' => $woff2], 'all', 'sans-serif'); } /** @runInSeparateProcess */ public function testRemoveDeletesTheFontAndReportsARefusal(): void { $fonts = Mockery::mock('alias:' . self::FONTS); $fonts->expects('font_remove')->with('Inter')->andReturn(['updated_fonts' => []]); $fonts->expects('font_remove')->with('Fehlt')->andReturn(new WP_Error('font_not_exist', 'Font Does not Exist.')); (new FontInstaller())->remove('Inter'); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('font_remove refused: Font Does not Exist.'); (new FontInstaller())->remove('Fehlt'); } } ``` - [ ] **Step 7: RED** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter FontInstallerTest` Expected: `Tests: 10, Assertions: 3, Errors: 7, Failures: 3.` – sieben `Error: Class "LIT\MCP\Integrations\Divi\Design\FontInstaller" not found`, drei `Failed asserting that exception of type "Error" matches expected exception …` (die `expectException`-Tests). - [ ] **Step 8: `FontInstaller` anlegen** `src/Integrations/Divi/Design/FontInstaller.php`: ```php false, 'type' => false, 'proper_filename' => false]; public static function manifest(): Manifest { return new Manifest( Manifest::DESIGN, calls: [ new Call(FontsUtility::class . '::mime_types_font', 0), new Call(HooksRegistration::class . '::check_filetype_and_ext_font', 3), new Call(FontsUtility::class . '::font_add', 4), new Call(FontsUtility::class . '::font_remove', 1), ], constants: [FontsUtility::class . '::FONT_NAME_MAX_LENGTH'], mirrors: [ new Mirror( 'font-install', 'includes/builder-5/server/VisualBuilder/AiAgent/AiAgentImageUtility.php', 'install_to_custom_fonts', 'AiAgentImageUtility', 'Aufrufkonvention von font_add() mit serverseitiger Temp-Datei (check_filetype_and_ext_font ' . 'vorab, wp_tempnam, action wp_handle_upload_rest)' ), ], features: [DiviFeature::FontUpload] ); } /** @return list FORMATS that Divi's font MIME list knows too. */ public function formats(): array { return array_values(array_intersect(self::FORMATS, array_keys(FontsUtility::mime_types_font()))); } /** Divi's bound for a font name (FontNameRules::check() takes it). */ public function nameMaxLength(): int { return (int) FontsUtility::FONT_NAME_MAX_LENGTH; } /** Whether Divi's content check (finfo) takes $path as a font of $filename's extension. */ public function accepts(string $path, string $filename): bool { return $this->mimeOf($path, $filename) !== null; } /** * @param array $files Extension => temp file path (deleted or moved by WordPress). * @return list The extensions Divi stored – fewer than $files on a partial success. * @throws DiviLocked When font-upload is not released – before anything is written. * @throws RuntimeException When Divi refuses a file or the whole font; nothing is stored then. */ public function install(string $name, array $files, string $weights, string $family): array { ReleaseGate::assertFeature(DiviFeature::FontUpload); $uploads = []; foreach ($files as $extension => $path) { $filename = sanitize_file_name($name . '.' . $extension); $mime = $this->mimeOf($path, $filename); if ($mime === null) { throw new RuntimeException(sprintf("font file %s refused by Divi's type check", $extension)); } clearstatcache(true, $path); $uploads[$extension] = [ 'name' => $filename, 'type' => $mime, 'tmp_name' => $path, 'error' => 0, 'size' => (int) filesize($path), ]; } $result = FontsUtility::font_add( $uploads, $name, ['font_weights' => $weights, 'generic_family' => $family], ['action' => 'wp_handle_upload_rest'] ); if (is_wp_error($result)) { throw new RuntimeException('font_add refused: ' . $result->get_error_message()); } $stored = is_array($result) ? ($result['updated_fonts'][$name]['font_file'] ?? null) : null; return is_array($stored) ? array_map('strval', array_keys($stored)) : []; } /** @throws RuntimeException When Divi does not remove the font (e.g. it does not exist). */ public function remove(string $name): void { $result = FontsUtility::font_remove($name); if (is_wp_error($result)) { throw new RuntimeException('font_remove refused: ' . $result->get_error_message()); } } /** The MIME type Divi detected, or null when it refuses the content or names another extension. */ private function mimeOf(string $path, string $filename): ?string { $checked = HooksRegistration::check_filetype_and_ext_font(self::UNCHECKED, $path, $filename); $extension = strtolower((string) ($checked['ext'] ?? '')); $type = (string) ($checked['type'] ?? ''); return $type !== '' && $extension !== '' && $extension === strtolower(pathinfo($filename, PATHINFO_EXTENSION)) ? $type : null; } } ``` - [ ] **Step 9: Adapter in `DiviAdapters::CLASSES` aufnehmen** In `src/Integrations/Divi/Release/DiviAdapters.php` den Import `use LIT\MCP\Integrations\Divi\Design\FontInstaller;` (alphabetisch bei den `Design`-Importen) ergänzen und `FontInstaller::class,` in `CLASSES` **direkt hinter `FontCatalog::class`** (Task 2) einfügen – Schriftliste und Upload stehen so im Bericht nebeneinander: ```php FontCatalog::class, FontInstaller::class, ``` - [ ] **Step 10: GREEN Adapter, erwartete rote Tabellen** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter FontInstallerTest` Expected: `OK (10 tests, 31 assertions)` Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'FontInstallerTest|DiviFeatureAssignmentTest|DiviBoundaryTest|DiviReferenceTest|DiviCheckCommandTest'` Expected: `Tests: 43, …, Failures: 6.` – genau `DiviFeatureAssignmentTest::testTheAssignmentIsThePinnedTable` (neue Zeile fehlt, Step 11), `DiviReferenceTest::testEveryMirrorAndEveryCallHasAnExpectedFingerprintAndEveryMirrorAReferenceSource` (Fingerabdrücke fehlen, Step 13) und vier `DiviCheckCommandTest::testUpdateReference…` (der Wegwerf-Theme-Ordner des Tests kennt den neuen Mirror nicht, Step 12). `DiviBoundaryTest` ist grün (der Adapter nutzt genau `ET\Builder\VisualBuilder\Fonts\FontsUtility` und `…\Hooks\HooksRegistration`; `wp_handle_upload_rest` und der Klassen-Konstantenname sind für `DiviTouches` keine Divi-Namen). - [ ] **Step 11: Zuordnungstabelle anpassen** In `tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php` den Import `use LIT\MCP\Integrations\Divi\Design\FontInstaller;` ergänzen und in `EXPECTED` direkt hinter dem Block von `FontCatalog::class` (gleiche Reihenfolge wie `CLASSES`) einfügen: ```php FontInstaller::class => [ 'FontsUtility::mime_types_font()' => ['font-upload'], 'HooksRegistration::check_filetype_and_ext_font()' => ['font-upload'], 'FontsUtility::font_add()' => ['font-upload'], 'FontsUtility::font_remove()' => ['font-upload'], 'Konstante ET\\Builder\\VisualBuilder\\Fonts\\FontsUtility::FONT_NAME_MAX_LENGTH' => ['font-upload'], 'Nachbau AiAgentImageUtility::install_to_custom_fonts()' => ['font-upload'], ], ``` Den Text `'all ten adapters, in report order'` in `testTheAssignmentIsThePinnedTable` auf die neue Zahl bringen (nach Task 2 und diesem Task: `'all thirteen adapters, in report order'`). - [ ] **Step 12: `DiviCheckCommandTest` kennt den neuen Mirror** `writeDivi()` legt für jeden Mirror eine Minimal-Quelle unter seinem echten Pfad an; `--update-reference` liest alle Mirrors. In `tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php` (auf dem Stand nach Task 2 – falls Task 2 dort schon einen Mirror ergänzt hat, zählen beide): ```diff --- a/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php +++ b/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php @@ -228,14 +228,14 @@ final class DiviCheckCommandTest extends TestCase self::assertSame(0, $code); self::assertSame( [ - 'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed', 'tb-api-save', - 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags', + 'font-install', 'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed', + 'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags', ], array_keys(DiviReference::expected($this->expectedFile)) ); self::assertSame(str_repeat('b', 64), DiviReference::expected($this->expectedFile)['sig:A::b']); self::assertContains( - 'Referenz aktualisiert (6 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.', + 'Referenz aktualisiert (7 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.', $this->lines ); self::assertStringStartsWith( @@ -244,7 +244,7 @@ final class DiviCheckCommandTest extends TestCase ); } - /** Minimal stand-ins for the six mirrored Divi functions at their real paths. */ + /** Minimal stand-ins for the seven mirrored Divi functions at their real paths. */ private function writeDivi(): void { $files = [ @@ -257,6 +257,8 @@ final class DiviCheckCommandTest extends TestCase "class SyncToServerController {\n\tpublic static function update( \$request ) {\n\t}\n}", 'includes/builder-5/server/Packages/GlobalData/GlobalPresetController.php' => "class GlobalPresetController {\n\tpublic static function sync( \$request ) {\n\t}\n}", + 'includes/builder-5/server/VisualBuilder/AiAgent/AiAgentImageUtility.php' => + "class AiAgentImageUtility {\n\tpublic static function install_to_custom_fonts( \$a, \$b ) {\n\t}\n}", ]; foreach ($files as $path => $code) { $file = $this->themeDir . '/' . $path; ``` (Ausgangsstand ohne weitere Mirrors von Task 2: sechs → sieben; jeder weitere Mirror aus Task 2 erhöht beide Zahlen um eins, `'font-install'` steht alphabetisch vorn.) Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'FontInstallerTest|DiviFeatureAssignmentTest|DiviBoundaryTest|DiviReferenceTest|DiviCheckCommandTest'` Expected: genau ein Fehlschlag, `DiviReferenceTest::testEveryMirrorAndEveryCallHasAnExpectedFingerprintAndEveryMirrorAReferenceSource`. - [ ] **Step 13: Fingerabdrücke in DDEV erzeugen** Befehl und Bedingungen wie Task 2 des Plans 4c, Step 11 (`wp lit-mcp divi-check --update-reference` verlangt einen Administrator, schreibt nur, wenn außer „Fingerabdrücke“ alle Prüfschritte grün sind, nur in der Entwicklungskopie; schreibt die Referenzquellen aller Mirrors und `fingerprints.json`). Plugins `postlos` und `asynclistry` vorher deaktivieren, Zustand vorher mit `ddev exec wp plugin list` notieren und danach wiederherstellen. ```bash cd /home/janniclas/Projekte/WordPress ddev start ddev exec wp plugin list ddev wp plugin deactivate postlos asynclistry ddev exec bash -c 'wp --user="$(wp user list --role=administrator --field=user_login | head -1)" lit-mcp divi-check --update-reference' ddev wp plugin activate postlos asynclistry ``` Expected: Prüfschritte mit `✓` („Fingerabdrücke nachgebauter Divi-Funktionen“ darf `✗` sein), dann `Referenz aktualisiert ( Fingerabdrücke, Signaturen). …` mit `` = Zahl der Mirrors nach Task 2 + 1 und `` = Zahl der `sig:`-Schlüssel nach Task 2 + 4; Exit-Code 0. Ergebnis prüfen: ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git diff -U0 src/Integrations/Divi/Release/fingerprints.json | grep '^[-+] ' git status --short tests/Fixtures/divi/reference ``` Expected: genau diese fünf `+`-Zeilen (Divi 5.15.1; im Arbeitsstand des Planers mit Divis Quellen nachgerechnet) und keine `-`-Zeile; unter `tests/Fixtures/divi/reference` nur `?? tests/Fixtures/divi/reference/font-install.txt` (148 Zeilen, beginnt mit `function install_to_custom_fonts( string $chat_id, string $filename, string $font_name = '' ) {`): ``` + "font-install": "a6b7c0f26437f9ea63186138e130f3c226c0cc017d424807e1030b44c165ea88", + "sig:ET\\Builder\\VisualBuilder\\Fonts\\FontsUtility::font_add": "028c32cd79e1f33dceebf23c4c08d39c7b8062da01fb3006318ce21597d55103", + "sig:ET\\Builder\\VisualBuilder\\Fonts\\FontsUtility::font_remove": "ab573a0776e9ba79e9606d3df9b1d2adb77e00e6eaf4120710a7649bcaf68ba0", + "sig:ET\\Builder\\VisualBuilder\\Fonts\\FontsUtility::mime_types_font": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae", + "sig:ET\\Builder\\VisualBuilder\\Hooks\\HooksRegistration::check_filetype_and_ext_font": "d98fd02bab7ba06af60ac0a99fbc717f916dd126d19c8f2e69779ab83d99902f", ``` Ändert sich ein anderer Schlüssel oder eine andere Referenzquelle, **nicht committen**, sondern prüfen (installiertes Divi ≠ 5.15.1 oder Task 2 hat seine Referenz nicht committet). - [ ] **Step 14: GREEN, Gesamtlauf, Lint, Commit** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'FontInstallerTest|DiviFeatureAssignmentTest|DiviBoundaryTest|DiviReferenceTest|DiviCheckCommandTest'` Expected: `OK (43 tests, 224 assertions)` (Zahlen auf dem Stand ohne Task-2-Erweiterungen dieser Tests; mit ihnen entsprechend mehr, aber kein Fehlschlag). Run: `composer test && composer lint` Expected: OK, Lint sauber. ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git add src/Integrations/Divi/Design/FontInstaller.php tests/Unit/Integrations/Divi/Design/FontInstallerTest.php \ src/Integrations/Divi/Release/DiviAdapters.php tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php \ tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php \ src/Integrations/Divi/Release/fingerprints.json tests/Fixtures/divi/reference/font-install.txt git commit -m "feat: FontInstaller – Schrift-Upload über FontsUtility::font_add im Prozess (Manifest, Mirror font-install, Signatur-Referenzen)" -m "Co-Authored-By: Claude " ``` #### 8c – `lit/divi-fonts-upload` - [ ] **Step 15: Failing Test schreiben** `tests/Unit/Integrations/Divi/Design/DiviFontsUploadAbilityTest.php` – `FontCatalog` und `FontInstaller` per Mockery, der Download über einen Fake von `RemoteFileFetcher` (schreibt den Inhalt je URL in die Temp-Datei, die die Ability anlegt), `wp_tempnam` legt echte Temp-Dateien an (die Tests prüfen, dass sie danach weg sind), echte `FontSignature`/`FontNameRules`, `FakeChangeLog`: ```php */ private array $tempFiles = []; /** @var array URL => body the fake fetcher writes (null = download fails). */ private array $bodies = []; /** @var list */ private array $fetches = []; /** @var array Path => content of the temp files when install() ran. */ private array $installedFrom = []; private string $errorLog = ''; private string|false $previousErrorLog = false; protected function setUp(): void { parent::setUp(); $this->errorLog = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log'); $this->previousErrorLog = ini_set('error_log', $this->errorLog); $this->bodies = [self::WOFF2_URL => self::WOFF2, self::TTF_URL => self::TTF]; Functions\when('current_user_can')->justReturn(true); Functions\when('get_option')->justReturn(['upload_mb' => 1]); Functions\when('wp_tempnam')->alias(function (): string { $file = (string) tempnam(sys_get_temp_dir(), 'lit-font'); $this->tempFiles[] = $file; return $file; }); Functions\when('wp_delete_file')->alias(static function (string $file): void { if (is_file($file)) { unlink($file); } }); } protected function tearDown(): void { ini_set('error_log', (string) $this->previousErrorLog); foreach ([...$this->tempFiles, $this->errorLog] as $file) { if (is_file($file)) { unlink($file); } } parent::tearDown(); } private function fetcher(): RemoteFileFetcher { $test = $this; return new class ($test) extends RemoteFileFetcher { public function __construct(private DiviFontsUploadAbilityTest $test) { parent::__construct(static fn (string $host): array => ['93.184.216.34']); } public function allows(string $url): bool { return str_starts_with($url, 'https://') && ! str_contains($url, 'intern'); } public function fetch(string $url, string $tmp, int $maxBytes, string $context): ?array { return $this->test->fakeFetch($url, $tmp, $maxBytes, $context); } }; } /** The fake fetcher's download: writes the body of $url into $tmp. */ public function fakeFetch(string $url, string $tmp, int $maxBytes, string $context): ?array { $this->fetches[] = ['url' => $url, 'tmp' => $tmp, 'max' => $maxBytes, 'context' => $context]; $body = $this->bodies[$url] ?? null; if ($body === null) { return null; } file_put_contents($tmp, substr($body, 0, $maxBytes + 1)); return ['response' => ['code' => 200], 'headers' => []]; } /** @param list $reserved */ private static function catalog(array $reserved = ['Open Sans', 'Arial'], bool $canManage = true): FontCatalog { $catalog = Mockery::mock(FontCatalog::class); $catalog->allows('reserved')->andReturn($reserved); $catalog->allows('canManage')->andReturn($canManage); return $catalog; } private static function installer(bool $accepts = true): FontInstaller&MockInterface { $installer = Mockery::mock(FontInstaller::class); $installer->allows('formats')->andReturn(['woff2', 'woff', 'ttf', 'otf']); $installer->allows('nameMaxLength')->andReturn(100); $installer->allows('accepts')->andReturn($accepts); return $installer; } private function ability( ?FontInstaller $installer = null, ?FakeChangeLog $log = null, ?FontCatalog $catalog = null ): DiviFontsUploadAbility { return new DiviFontsUploadAbility( $catalog ?? self::catalog(), $installer ?? self::installer(), $this->fetcher(), $log ?? FakeChangeLog::forbidden(), new Limits() ); } /** @return array */ private static function input(array $overrides = []): array { return array_merge([ 'name' => 'Inter Display', 'files' => ['woff2' => self::WOFF2_URL, 'ttf' => self::TTF_URL], 'weights' => [700, 400], ], $overrides); } private function assertTempFilesRemoved(): void { self::assertNotSame([], $this->tempFiles, 'the files were downloaded'); foreach ($this->tempFiles as $file) { self::assertFileDoesNotExist($file); } } public function testIsAnIndirectAdditiveDiviToolForFontUpload(): void { $ability = $this->ability(); self::assertSame('lit/divi-fonts-upload', $ability->name()); self::assertFalse($ability->isDirect()); self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => false], $ability->annotations()); self::assertSame([DiviFeature::FontUpload], $ability->diviFeatures([])); self::assertSame(['name', 'files', 'weights'], $ability->inputSchema()['required']); self::assertFalse($ability->inputSchema()['properties']['files']['additionalProperties']); } public function testPermissionAndExecuteAskTheFontRights(): void { $ability = $this->ability(catalog: self::catalog(canManage: false)); self::assertToolError($ability->permission(self::input()), 'forbidden'); self::assertToolError($ability->execute(self::input()), 'forbidden'); Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'upload_files'); self::assertToolError($this->ability()->permission(self::input()), 'forbidden'); self::assertToolError($this->ability()->execute(self::input()), 'forbidden'); self::assertSame([], $this->fetches, 'nothing is downloaded without the rights'); } public function testEveryInputProblemIsReportedAtOnceWithItsPath(): void { $result = $this->ability()->execute([ 'name' => 'Inter (Pro)', 'files' => ['woff2' => 'http://intern.example.com/a.woff2', 'eot' => self::TTF_URL, 'ttf' => 5], 'weights' => [400, 450, 400], 'generic_family' => 'serifs', 'confirm' => 'yes', 'style' => 'italic', ]); $fields = self::errorFields($result); self::assertToolError($result, 'validation_failed'); self::assertStringContainsString('„Inter Pro“', $fields['name']); self::assertStringContainsString('öffentlich', $fields['files.woff2']); self::assertStringContainsString('erlaubt: woff2, woff, ttf, otf', $fields['files.eot']); self::assertSame('URL als Text erwartet', $fields['files.ttf']); self::assertStringContainsString('100, 200, 300, 400, 500, 600, 700, 800, 900', $fields['weights']); self::assertStringContainsString('sans-serif', $fields['generic_family']); self::assertSame('true oder false', $fields['confirm']); self::assertSame('unbekanntes Feld', $fields['style']); self::assertSame([], $this->fetches); } public function testFilesAndWeightsNeedTheirShape(): void { $fields = self::errorFields($this->ability()->execute(self::input(['files' => [], 'weights' => []]))); self::assertSame('1 bis 4 Formate als Objekt {Format: URL}', $fields['files']); self::assertStringContainsString('"all" oder Liste', $fields['weights']); $fields = self::errorFields($this->ability()->execute(self::input([ 'files' => [self::WOFF2_URL], 'weights' => 'bold', ]))); self::assertSame('1 bis 4 Formate als Objekt {Format: URL}', $fields['files']); self::assertStringContainsString('"all" oder Liste', $fields['weights']); $fields = self::errorFields($this->ability()->execute(['files' => ['woff2' => self::WOFF2_URL]])); self::assertStringContainsString('Name fehlt', $fields['name']); self::assertStringContainsString('"all" oder Liste', $fields['weights']); } public function testAFormatDiviDoesNotKnowIsRefused(): void { $installer = Mockery::mock(FontInstaller::class); $installer->allows('formats')->andReturn(['ttf', 'otf']); $installer->allows('nameMaxLength')->andReturn(100); $fields = self::errorFields($this->ability($installer)->execute(self::input())); self::assertStringContainsString('unterstützt Divi auf dieser Website nicht', $fields['files.woff2']); self::assertArrayNotHasKey('files.ttf', $fields); } public function testANameThatIsTakenWhateverTheCaseIsAConflict(): void { $error = self::assertToolError( $this->ability(catalog: self::catalog(['Open Sans', 'Inter Display']))->execute( self::input(['name' => 'inter display']) ), 'conflict' ); self::assertStringContainsString('„Inter Display“', $error['message']); self::assertSame([], $this->fetches); } public function testTheDryRunDownloadsAndChecksEveryFileAndAsksForConfirmation(): void { $installer = self::installer(); $installer->expects('install')->never(); $error = self::assertToolError($this->ability($installer)->execute(self::input()), 'confirm_required'); self::assertStringContainsString('„Inter Display“', $error['message']); self::assertStringContainsString('ganzen Website', $error['message']); self::assertStringContainsString('Nichts wurde gespeichert (Probelauf).', $error['message']); self::assertSame('Inter Display', $error['font']); self::assertSame([ ['format' => 'woff2', 'bytes' => strlen(self::WOFF2), 'sha256' => hash('sha256', self::WOFF2)], ['format' => 'ttf', 'bytes' => strlen(self::TTF), 'sha256' => hash('sha256', self::TTF)], ], $error['files']); self::assertSame('400,700', $error['weights']); self::assertSame('sans-serif', $error['generic_family']); self::assertSame([self::WOFF2_URL, self::TTF_URL], array_column($this->fetches, 'url')); self::assertSame(['font upload', 'font upload'], array_column($this->fetches, 'context')); self::assertSame([1048576, 1048576], array_column($this->fetches, 'max'), 'upload limit 1 MB'); $this->assertTempFilesRemoved(); } public function testTheSizeCapIsFiveMegabytesEvenWithAHigherUploadLimit(): void { Functions\when('get_option')->justReturn(['upload_mb' => 32]); $this->ability()->execute(self::input()); self::assertSame([5242880, 5242880], array_column($this->fetches, 'max')); } public function testAFileAboveTheCapIsInvalid(): void { $this->bodies[self::TTF_URL] = self::TTF . str_repeat('x', 1048576); $result = $this->ability()->execute(self::input()); self::assertToolError($result, 'font_invalid'); self::assertSame('größer als 1 MB', self::errorFields($result)['files.ttf']); self::assertArrayNotHasKey('files.woff2', self::errorFields($result)); $this->assertTempFilesRemoved(); } public function testTheContentMustBeTheFormatOfItsKey(): void { $this->bodies[self::WOFF2_URL] = self::TTF; $this->bodies[self::TTF_URL] = 'ability()->execute(self::input()); $fields = self::errorFields($result); self::assertToolError($result, 'font_invalid'); self::assertSame('Inhalt ist eine ttf-Datei, keine woff2-Datei', $fields['files.woff2']); self::assertSame('Inhalt ist keine Schriftdatei', $fields['files.ttf']); $this->assertTempFilesRemoved(); } public function testAnEmptyFileIsInvalid(): void { $this->bodies[self::TTF_URL] = ''; $result = $this->ability()->execute(self::input()); self::assertToolError($result, 'font_invalid'); self::assertSame('leere Datei', self::errorFields($result)['files.ttf']); } public function testAFileDivisContentCheckRefusesIsInvalid(): void { $result = $this->ability(self::installer(accepts: false))->execute(self::input()); self::assertToolError($result, 'font_invalid'); self::assertSame('von Divi nicht als Schriftdatei erkannt', self::errorFields($result)['files.woff2']); $this->assertTempFilesRemoved(); } public function testAFailedDownloadIsFontFetchFailedWithoutAnOracle(): void { $this->bodies[self::TTF_URL] = null; $this->bodies[self::WOFF2_URL] = self::TTF; $result = $this->ability()->execute(self::input()); $error = self::assertToolError($result, 'font_fetch_failed'); self::assertSame( ['files.woff2' => 'Inhalt ist eine ttf-Datei, keine woff2-Datei', 'files.ttf' => 'nicht abrufbar'], self::errorFields($result) ); self::assertStringContainsString('nicht geladen', $error['message']); $this->assertTempFilesRemoved(); } public function testConfirmInstallsFromTheCheckedTempFilesAndLogsTheChecksums(): void { $installer = self::installer(); $installer->expects('install')->once() ->with('Inter Display', Mockery::type('array'), '400,700', 'serif') ->andReturnUsing(function (string $name, array $files): array { $this->installedFrom = array_map('file_get_contents', $files); return array_keys($files); }); $log = new FakeChangeLog(7); $result = $this->ability($installer, $log)->execute( self::input(['generic_family' => 'serif', 'confirm' => true]) ); self::assertSame(['woff2' => self::WOFF2, 'ttf' => self::TTF], $this->installedFrom); self::assertTrue($result['ok']); self::assertSame('Inter Display', $result['font']); self::assertSame(['woff2', 'ttf'], array_column($result['files'], 'format')); self::assertSame(7, $result['log_id']); self::assertSame([['id' => 'Inter Display', 'type' => 'divi_font', 'action' => 'created', 'label' => 'Inter Display']], $result['changed']); self::assertCount(1, $log->records); self::assertSame('lit/divi-fonts-upload', $log->records[0]['ability']); self::assertSame('divi_font', $log->records[0]['object_type']); self::assertSame('Inter Display', $log->records[0]['object_id']); self::assertStringContainsString('woff2 ' . hash('sha256', self::WOFF2), $log->records[0]['summary']); self::assertStringContainsString('ttf ' . hash('sha256', self::TTF), $log->records[0]['summary']); self::assertSame(2, count($this->fetches), 'downloaded and checked again for the install'); $this->assertTempFilesRemoved(); } public function testWeightsAllArePassedOn(): void { $installer = self::installer(); $installer->expects('install')->once()->with('Inter Display', Mockery::type('array'), 'all', 'sans-serif') ->andReturn(['woff2', 'ttf']); $result = $this->ability($installer, new FakeChangeLog())->execute( self::input(['weights' => 'all', 'confirm' => true]) ); self::assertSame('all', $result['weights']); } public function testAPartialInstallIsRemovedAgain(): void { $installer = self::installer(); $installer->allows('install')->andReturn(['woff2']); $installer->expects('remove')->once()->with('Inter Display'); try { $this->ability($installer)->execute(self::input(['confirm' => true])); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); $error = self::assertToolError($e->toError(), 'write_failed'); self::assertStringContainsString('ttf', $error['message']); } $this->assertTempFilesRemoved(); } public function testAPartialInstallThatCannotBeRemovedIsAPartialWrite(): void { $installer = self::installer(); $installer->allows('install')->andReturn(['woff2']); $installer->allows('remove')->andThrow(new RuntimeException('font_remove refused: Font Does not Exist.')); $log = FakeChangeLog::partialsOnly(); try { $this->ability($installer, $log)->execute(self::input(['confirm' => true])); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertFalse($e->rolledBack); $error = self::assertToolError($e->toError(), 'partial_write'); self::assertStringContainsString('„Inter Display“', $error['message']); } self::assertCount(1, $log->partials); self::assertSame('divi_font', $log->partials[0]['object_type']); } public function testARefusedInstallIsAWriteFailureThatLeavesNothing(): void { $installer = self::installer(); $installer->allows('install')->andThrow(new RuntimeException('font_add refused: kaputt')); $installer->expects('remove')->never(); try { $this->ability($installer)->execute(self::input(['confirm' => true])); self::fail('WriteFailed expected'); } catch (WriteFailed $e) { self::assertTrue($e->rolledBack); self::assertStringNotContainsString('kaputt', Errors::clientMessage($e->toError())); } $this->assertTempFilesRemoved(); } } ``` - [ ] **Step 16: RED** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviFontsUploadAbilityTest` Expected: `Tests: 18, Assertions: 1, Errors: 18.` – jeweils `Error: Class "LIT\MCP\Integrations\Divi\Design\DiviFontsUploadAbility" not found`. - [ ] **Step 17: Ability anlegen** `src/Integrations/Divi/Design/DiviFontsUploadAbility.php`: ```php 'string', 'description' => 'Öffentliche http(s)-URL der Datei.']; return [ 'type' => 'object', 'properties' => [ 'name' => ['type' => 'string', 'description' => 'Name der Schrift (Familienname in Divi).'], 'files' => [ 'type' => 'object', 'properties' => array_fill_keys(FontInstaller::FORMATS, $url), 'additionalProperties' => false, ], 'weights' => [ 'type' => ['string', 'array'], 'items' => ['type' => 'integer'], 'description' => '"all" oder Liste der Schriftstärken aus 100, 200, … 900.', ], 'generic_family' => ['type' => 'string', 'enum' => self::FAMILIES], 'confirm' => ['type' => 'boolean'], ], 'required' => ['name', 'files', 'weights'], 'additionalProperties' => false, ]; } public function outputSchema(): ?array { return ToolResult::schema([ 'font' => ['type' => 'string'], 'files' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'format' => ['type' => 'string'], 'bytes' => ['type' => 'integer'], 'sha256' => ['type' => 'string'], ], ], ], 'weights' => ['type' => 'string'], 'generic_family' => ['type' => 'string'], ], ['font', 'files', 'weights', 'generic_family']); } public function annotations(): array { return Annotations::write(false); } public function isDirect(): bool { return false; } /** FontInstaller and the font lists of FontCatalog (name collision) both serve font-upload (ruling W1). */ public function diviFeatures(array $input): array { return [DiviFeature::FontUpload]; } public function permission(array $input): bool|WP_Error { return DesignRights::fonts($this->catalog) ?? true; } /** @throws WriteFailed */ public function execute(array $input): mixed { $refusal = DesignRights::fonts($this->catalog); if ($refusal !== null) { return $refusal; } $errors = []; foreach (array_diff(array_map('strval', array_keys($input)), self::KEYS) as $key) { $errors[$key] = 'unbekanntes Feld'; } $name = is_string($input['name'] ?? null) ? $input['name'] : ''; $taken = FontNameRules::taken($name, $this->catalog->reserved()); $nameProblem = $taken === null ? FontNameRules::check($name, [], $this->installer->nameMaxLength()) : null; if ($nameProblem !== null) { $errors['name'] = $nameProblem; } $urls = $this->urls($input['files'] ?? null, $errors); $weights = self::weights($input['weights'] ?? null, $errors); $family = $input['generic_family'] ?? self::DEFAULT_FAMILY; if (! in_array($family, self::FAMILIES, true)) { $errors['generic_family'] = 'eine von: ' . implode(', ', self::FAMILIES); } $confirm = $input['confirm'] ?? false; if (! is_bool($confirm)) { $errors['confirm'] = 'true oder false'; } if ($errors !== []) { return Errors::invalidInput('Ungültige Angaben zur Schrift; nichts wurde gespeichert.', $errors); } if ($taken !== null) { return Errors::make( 'conflict', sprintf('Eine Schrift „%s“ gibt es schon; nichts wurde gespeichert.', $taken), 'Einen anderen Namen wählen (Groß-/Kleinschreibung zählt nicht) oder die vorhandene Schrift verwenden.', 409 ); } return $this->upload($name, $urls, $weights, (string) $family, $confirm === true); } /** * @param array $errors * @return array Format => URL, in the order of the input. */ private function urls(mixed $files, array &$errors): array { if (! is_array($files) || $files === [] || array_is_list($files) || count($files) > 4) { $errors['files'] = self::FILE_RULE; return []; } $known = $this->installer->formats(); $urls = []; foreach ($files as $format => $url) { $field = 'files.' . $format; if (! in_array($format, FontInstaller::FORMATS, true)) { $errors[$field] = 'unbekanntes Format; erlaubt: ' . implode(', ', FontInstaller::FORMATS); } elseif (! in_array($format, $known, true)) { $errors[$field] = 'dieses Format unterstützt Divi auf dieser Website nicht'; } elseif (! is_string($url) || $url === '') { $errors[$field] = 'URL als Text erwartet'; } elseif (! $this->fetcher->allows($url)) { $errors[$field] = 'nur öffentlich erreichbare http(s)-Adressen'; } else { $urls[$format] = $url; } } return $urls; } /** * @param array $errors * @return string "all" or the sorted list as Divi stores it ("400,700"). */ private static function weights(mixed $weights, array &$errors): string { if ($weights === 'all') { return 'all'; } if ( ! is_array($weights) || $weights === [] || ! array_is_list($weights) || array_filter($weights, 'is_int') !== $weights || array_diff($weights, self::WEIGHTS) !== [] || count(array_unique($weights)) !== count($weights) ) { $errors['weights'] = '"all" oder Liste verschiedener Schriftstärken aus ' . implode(', ', self::WEIGHTS); return ''; } sort($weights); return implode(',', $weights); } /** * Downloads and checks every file into temp files – for the dry run and again for the install – and deletes * them on every path. * * @param array $urls * @return array|WP_Error * @throws WriteFailed */ private function upload(string $name, array $urls, string $weights, string $family, bool $confirm): array|WP_Error { AdminIncludes::load(); $temps = []; try { $files = $this->download($name, $urls, $temps); if ($files instanceof WP_Error) { return $files; } $report = array_values(array_map( static fn (array $file): array => array_diff_key($file, ['path' => true]), $files )); if (! $confirm) { return Errors::confirmRequired( sprintf( 'Schrift „%s“ hochladen (%s): Sie steht danach auf der ganzen Website in Divi zur Auswahl ' . '(Builder, Theme-Optionen, Schrift-Variablen). Nichts wurde gespeichert (Probelauf).', $name, implode(', ', array_keys($files)) ), ['font' => $name, 'files' => $report, 'weights' => $weights, 'generic_family' => $family] ); } $paths = array_map(static fn (array $file): string => $file['path'], $files); $this->install($name, $paths, $weights, $family); } finally { foreach ($temps as $tmp) { if (file_exists($tmp)) { wp_delete_file($tmp); } } } $summary = sprintf('Schrift „%s“ hochgeladen (%s).', $name, implode(', ', array_map( static fn (array $file): string => $file['format'] . ' ' . $file['sha256'], $report ))); $logId = $this->log->record($this->name(), self::OBJECT_TYPE, $name, $summary); return ToolResult::ok(sprintf( 'Schrift „%s“ hochgeladen; sie steht jetzt in Divi zur Auswahl (z. B. für lit/divi-theme-options-update).', $name )) ->changed($name, self::OBJECT_TYPE, 'created', $name) ->logId($logId) ->with('font', $name) ->with('files', $report) ->with('weights', $weights) ->with('generic_family', $family) ->toArray(); } /** * @param array $urls * @param list $temps Gets every temp file created (the caller deletes them). * @return array|WP_Error */ private function download(string $name, array $urls, array &$temps): array|WP_Error { $max = min($this->limits->uploadBytes(), self::MAX_FILE_BYTES); $files = []; $problems = []; $unreachable = false; foreach ($urls as $format => $url) { $tmp = (string) wp_tempnam('lit-mcp-font'); if ($tmp === '') { return Errors::writeFailed('Die temporäre Datei konnte nicht angelegt werden.'); } $temps[] = $tmp; if ($this->fetcher->fetch($url, $tmp, $max, 'font upload') === null) { $problems['files.' . $format] = 'nicht abrufbar'; $unreachable = true; continue; } $problem = $this->problemOf($tmp, $format, $name . '.' . $format, $max); if ($problem !== null) { $problems['files.' . $format] = $problem; continue; } $files[$format] = [ 'format' => $format, 'bytes' => (int) filesize($tmp), 'sha256' => (string) hash_file('sha256', $tmp), 'path' => $tmp, ]; } if ($unreachable) { return Errors::make( 'font_fetch_failed', 'Mindestens eine Schriftdatei konnte nicht geladen werden; nichts wurde gespeichert.', 'URLs prüfen; nur öffentlich erreichbare Dateien lassen sich laden.', 400, ['fields' => $problems] ); } return $problems === [] ? $files : Errors::make( 'font_invalid', 'Mindestens eine Datei ist keine gültige Schriftdatei ihres Formats; nichts wurde gespeichert.', sprintf( 'Je Format die passende Datei angeben (woff2, woff, ttf oder otf, höchstens %d MB je Datei).', intdiv($max, 1024 * 1024) ), 400, ['fields' => $problems] ); } /** The problem of a downloaded file (size, magic bytes, Divi's content check), null when it is fine. */ private function problemOf(string $tmp, string $format, string $filename, int $max): ?string { clearstatcache(true, $tmp); $bytes = (int) filesize($tmp); if ($bytes === 0) { return 'leere Datei'; } if ($bytes > $max) { return sprintf('größer als %d MB', intdiv($max, 1024 * 1024)); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp file. $header = (string) file_get_contents($tmp, false, null, 0, FontSignature::HEADER_BYTES); $detected = FontSignature::detect($header); if ($detected === null) { return 'Inhalt ist keine Schriftdatei'; } if ($detected !== $format) { return sprintf('Inhalt ist eine %s-Datei, keine %s-Datei', $detected, $format); } return $this->installer->accepts($tmp, $filename) ? null : 'von Divi nicht als Schriftdatei erkannt'; } /** * @param array $paths Format => checked temp file. * @throws WriteFailed When Divi refuses the font or stores only some files (removed again if possible). */ private function install(string $name, array $paths, string $weights, string $family): void { try { $installed = $this->installer->install($name, $paths, $weights, $family); } catch (RuntimeException $e) { // FontInstaller stores nothing when it throws. throw WriteFailed::rolledBack(self::REASON, $e, 'font upload: ' . $e->getMessage()); } $missing = array_values(array_diff(array_keys($paths), $installed)); if ($missing === []) { return; } $detail = sprintf( 'font upload: Divi stored %s of %s', implode(',', $installed), implode(',', array_keys($paths)) ); $failures = Rollback::attempt(fn () => $this->installer->remove($name)); if ($failures === []) { throw WriteFailed::rolledBack( self::REASON . sprintf(' Divi hat %s nicht gespeichert.', implode(', ', $missing)), detail: $detail ); } $this->log->recordPartial( $this->name(), self::OBJECT_TYPE, $name, sprintf('Schrift „%s“ nur teilweise hochgeladen, nicht entfernt (%s)', $name, $failures[0]) ); throw WriteFailed::partial( self::REASON, detail: $detail . '; remove failed: ' . $failures[0], state: sprintf('Die Schrift „%s“ ist ohne %s in Divi gespeichert.', $name, implode(', ', $missing)), hint: 'Den Aufruf nicht wiederholen; die Schrift im Divi-Builder (Schriften verwalten) prüfen und ggf. ' . 'löschen und dem Nutzer Bescheid geben.' ); } } ``` - [ ] **Step 18: GREEN** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter DiviFontsUploadAbilityTest` Expected: `OK (18 tests, 178 assertions)` - [ ] **Step 19: Gesamtlauf, Lint, Commit** Die Ability ist noch nicht verdrahtet (Task 9); `DiviIntegrationTest`/`DiviToolFeaturesTest` bleiben unverändert grün. Run: `composer test && composer lint` Expected: OK – Basis von Task 7 + 35 Tests (8a: 7, 8b: 10, 8c: 18); im Prüflauf des Planers mit Platzhaltern für Task 1–2 auf dem Stand 2307027: `OK (3161 tests, …)` = 3121 + 40 (Tasks 7 und 8); Lint sauber. ```bash cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP git add src/Integrations/Divi/Design/DiviFontsUploadAbility.php \ tests/Unit/Integrations/Divi/Design/DiviFontsUploadAbilityTest.php git commit -m "feat: lit/divi-fonts-upload – eigene Schriften von URLs hochladen (Probelauf mit SHA-256, confirm, Rücknahme bei Teilerfolg)" -m "Co-Authored-By: Claude " ``` ### Task 9: Verdrahtung in `DiviIntegration`, Tabellen der Fähigkeiten, Protokoll-Label **Files:** - Modify: `src/Integrations/Divi/DiviIntegration.php` - Modify: `src/Admin/LogObjects.php` (Label für den Objekttyp `divi_font`, falls dort je Typ ein Label steht – erst lesen) - Modify: `tests/Unit/Integrations/Divi/DiviIntegrationTest.php` (Namensliste) - Modify: `tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php` (Tabelle `EXPECTED`) - Modify (nur falls ein Test den Objekttyp prüft): `tests/Unit/Admin/LogObjectsTest.php` **Interfaces:** - Consumes (so, wie die Tasks 2–8 sie tatsächlich gebaut haben – vor dem Verdrahten die echten Konstruktoren lesen): `ThemeOptionsAdapter()`, `FontCatalog()`, `FontInstaller()`, `RemoteFileFetcher()`, `Limits()` ohne Argumente; `DiviThemeOptionsGetAbility(ThemeOptionsAdapter)`; `DiviThemeOptionsUpdateAbility(DesignSystemWriter, ThemeOptionsAdapter, FontCatalog, DesignChange)`; `DiviFontsUploadAbility(FontCatalog, FontInstaller, RemoteFileFetcher, ChangeLog, Limits)`; `DiviVariablesSetAbility(…, FontCatalog $fonts, ?Closure $random = null)`; `ThemeOptionsRestorePlanner(DesignSystemWriter, ThemeOptionsAdapter)`. - Produces: die drei Tools in `DiviIntegration::abilities()`, **eine** Instanz je Adapter pro Integration (Eigenschaften `$themeOptionsAdapter`, `$fontCatalog`, `$fontInstaller`); die in Task 5 und Task 6 inline eingeführten `new FontCatalog()`/`new ThemeOptionsAdapter()` werden durch die geteilten Eigenschaften ersetzt (`FontCatalog` hält die große Schriftliste je Instanz – Task 2 –, darum nur eine pro Request). - [ ] **Step 1: Tests zuerst – Namensliste und Fähigkeitentabelle** `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`, in `EXPECTED` nach `'lit/divi-variables-set',` ergänzen: ```php 'lit/divi-theme-options-get', 'lit/divi-theme-options-update', 'lit/divi-fonts-upload', ``` `tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php`, in `EXPECTED` unter `// Design system (divi)` nach `'lit/divi-variables-set' => …` ergänzen – die Zeilen müssen genau die `diviFeatures()` der Abilities abbilden (zuerst `DiviThemeOptionsGetAbility::diviFeatures()`, `DiviThemeOptionsUpdateAbility::diviFeatures()` und `DiviFontsUploadAbility::diviFeatures()` lesen; die Erwartung unten ist die aus den Plänen der Tasks 4 und 8, die Tabelle ist der Vertrag und folgt dem Code, nicht umgekehrt): ```php 'lit/divi-theme-options-get' => ['theme-option-read'], 'lit/divi-theme-options-update' => ['theme-option-read', 'theme-option-write'], 'lit/divi-fonts-upload' => ['font-upload'], ``` Und in der zweiten Tabelle der Datei (jedem schreibenden Tool seine Schreib-Fähigkeit, Zeile mit `'lit/divi-colors-set' => 'color-write', …`) ergänzen: ```php 'lit/divi-theme-options-update' => 'theme-option-write', 'lit/divi-fonts-upload' => 'font-upload', ``` Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviIntegrationTest|DiviToolFeaturesTest/'` Expected: FAIL – `DiviIntegrationTest` (Namensliste ohne die drei Tools) und `DiviToolFeaturesTest` (Tabelle nennt Tools, die es nicht gibt). - [ ] **Step 2: Imports, Eigenschaften, Konstruktor** `src/Integrations/Divi/DiviIntegration.php` – Imports (zu den vorhandenen `Design\`-Imports; `FontCatalog`, `ThemeOptionsAdapter` und `ThemeOptionsRestorePlanner` stehen nach Task 5/6 teilweise schon dort): ```php use LIT\MCP\Integrations\Divi\Design\DiviFontsUploadAbility; use LIT\MCP\Integrations\Divi\Design\DiviThemeOptionsGetAbility; use LIT\MCP\Integrations\Divi\Design\DiviThemeOptionsUpdateAbility; use LIT\MCP\Integrations\Divi\Design\FontCatalog; use LIT\MCP\Integrations\Divi\Design\FontInstaller; use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter; use LIT\MCP\Integrations\Divi\Design\ThemeOptionsRestorePlanner; use LIT\MCP\Integrations\Media\RemoteFileFetcher; use LIT\MCP\Limits; ``` Eigenschaften neben `$colorsAdapter`/`$variablesAdapter`: ```php private ThemeOptionsAdapter $themeOptionsAdapter; private FontCatalog $fontCatalog; private FontInstaller $fontInstaller; ``` Im Konstruktor nach `$this->variablesAdapter = new GlobalVariablesAdapter();`: ```php $this->themeOptionsAdapter = new ThemeOptionsAdapter(); $this->fontCatalog = new FontCatalog(); $this->fontInstaller = new FontInstaller(); ``` - [ ] **Step 3: Tools registrieren, geteilte Adapter verwenden** In `abilities()`: die inline-`new FontCatalog()` des Variablen-Tools (Task 5) und `new ThemeOptionsAdapter()` des Restore-Planers (Task 6) durch `$this->fontCatalog` bzw. `$this->themeOptionsAdapter` ersetzen. Vorher (Restore, Planerliste): ```php new ThemeOptionsRestorePlanner($this->designWriter, new ThemeOptionsAdapter()) ``` nachher: ```php new ThemeOptionsRestorePlanner($this->designWriter, $this->themeOptionsAdapter) ``` Vorher (Variablen-Tool, letztes Konstruktorargument vor `$random`): ```php new FontCatalog() ``` nachher: ```php $this->fontCatalog ``` Nach dem Eintrag `new DiviVariablesSetAbility( … ),` anfügen: ```php new DiviThemeOptionsGetAbility($this->themeOptionsAdapter), new DiviThemeOptionsUpdateAbility( $this->designWriter, $this->themeOptionsAdapter, $this->fontCatalog, $this->designChange() ), new DiviFontsUploadAbility( $this->fontCatalog, $this->fontInstaller, new RemoteFileFetcher(), $this->log, new Limits() ), ``` (Die Argumente des `DiviFontsUploadAbility`-Konstruktors folgen der von Task 8 tatsächlich gebauten Signatur; weicht sie ab, gilt der Code.) - [ ] **Step 4: Protokoll-Label** Run: `grep -n "divi_design\|divi_font\|divi_" src/Admin/LogObjects.php` Steht dort je Objekttyp ein Label (z. B. `divi_design` → „Designsystem“), den Typ `divi_font` mit dem Label „Divi-Schrift“ ergänzen und im zugehörigen Test (`tests/Unit/Admin/LogObjectsTest.php`, falls vorhanden) eine Zeile; gibt es keine Typ-Liste, entfällt der Schritt (im Bericht vermerken). - [ ] **Step 5: GREEN, Gesamtlauf, Lint** Run: `php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviIntegrationTest|DiviToolFeaturesTest|IntegrationRegistryTest|ToolNamingTest/'` Expected: PASS. Run: `composer test && composer lint` Expected: alle Tests grün, phpcs ohne Befund. Schlägt ein Test fehl, der Tool-Namen oder Beschreibungen prüft (`ToolNamingTest`, Exposure-/Guide-Tests), zuerst lesen, was er verlangt (indirekte Tools heißen in Texten `lit/`), und den Text der Ability anpassen, nicht den Test. Run: `grep -rn "Etappe 4d\|folgen mit Etappe\|mit 4d" src docs/guides | grep -v "docs/superpowers"` Expected: keine Treffer, die behaupten, Theme-Optionen/Schriften/Restore von `theme_options` fehlten. Treffer in Guides, die nur auf spätere Guides (4e) verweisen, bleiben. - [ ] **Step 6: Commit** ```bash git add src/Integrations/Divi/DiviIntegration.php src/Admin/LogObjects.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php \ tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php git commit -m "feat: Theme-Optionen und Schriften-Upload in der Divi-Integration; je ein geteilter Adapter" -m "Co-Authored-By: Claude " ``` (`git add` nur die tatsächlich geänderten Dateien; `LogObjects`/`LogObjectsTest` nur, wenn Step 4 sie geändert hat.) ### Task 10: Smoke gegen echtes Divi (Theme-Optionen, Schriften-Upload, Restore) **Files:** - Create: `tests/Integration/divi-theme-fonts-smoke.sh` **Interfaces:** - Consumes: die Tools `lit/divi-theme-options-get`, `lit/divi-theme-options-update`, `lit/divi-fonts-upload`, `lit/divi-design-snapshots`, `lit/divi-design-restore` über den OAuth-Endpunkt und die Hilfen aus `tests/Integration/lib.sh` (`connect`, `ability`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`, `cleanup_app_passwords_for`). - Produces: ein Skript, das 4d gegen Divi 5.15.1 prüft und alles, was es ändert (`et_divi` byte-genau, `et_uploaded_fonts` samt Dateien), im EXIT-Trap zurücksetzt. Das Skript wird ausgeführt und – wie in Plan 4c – anhand der echten Antworten korrigiert. Die Feldnamen der Antworten (`.__error | fromjson`, `.fields`, `.snapshots[]`) kommen aus dem Stand der Tools nach Task 9; stimmt eine Annahme nicht, wird das Skript berichtigt (nicht die Prüfung gelockert). Die Prüfungen selbst sind verbindlich. - [ ] **Step 1: Das Skript anlegen** Datei `tests/Integration/divi-theme-fonts-smoke.sh`: ```bash #!/usr/bin/env bash # End-to-end checks of Etappe 4d (Divi theme options: read, change, unknown font with suggestions, reset to default, # snapshot + restore; custom font upload from a public URL) 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-theme-fonts-smoke.sh # Needs network access from the container (fetches one small woff2 from fonts.gstatic.com). The EXIT trap puts the # options et_divi and et_uploaded_fonts back byte-exact (serialized, base64 through the environment), removes the # uploaded font with Divi's own FontsUtility::font_remove (files included) and fails the script when a restore fails. 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)-$$" FONT_NAME="LIT Smoke $RUN" FONT_URL="https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu4mxK.woff2" capture() { wp eval "echo base64_encode(serialize(get_option('$1')));"; } # $1 = option name ORIGINAL_THEME=$(capture et_divi) ORIGINAL_FONTS=$(capture et_uploaded_fonts) [ -n "$ORIGINAL_THEME" ] || { echo "FAIL: could not capture et_divi"; exit 1; } [ -n "$ORIGINAL_FONTS" ] || { echo "FAIL: could not capture et_uploaded_fonts"; exit 1; } curl -fsS -m 20 -o /dev/null "$FONT_URL" || { echo "FAIL: $FONT_URL is not reachable from the container"; exit 1; } restore() { # $1 = option name, $2 = name of the environment variable that holds the capture local LIT_CAPTURE="$2"; export LIT_CAPTURE LIT_OPTION="$1" wp eval '$n = getenv("LIT_OPTION"); $v = unserialize(base64_decode(getenv(getenv("LIT_CAPTURE")))); update_option($n, $v); exit(serialize(get_option($n)) === serialize($v) ? 0 : 1);' >/dev/null 2>&1 \ || { echo "FAIL: $1 not restored"; return 1; } } cleanup() { local failed=0 # Remove the font through Divi (deletes the files), then put the option back exactly as it was. LIT_FONT="$FONT_NAME" wp eval 'ET\Builder\VisualBuilder\Fonts\FontsUtility::font_remove(getenv("LIT_FONT"));' >/dev/null 2>&1 || true restore et_uploaded_fonts LIT_FONTS || failed=1 restore et_divi LIT_THEME || failed=1 wp eval 'ET_Core_PageResource::remove_static_resources("all", "all", true);' >/dev/null 2>&1 || true cleanup_app_passwords_for "$ADMIN_LOGIN" return "$failed" } export LIT_THEME="$ORIGINAL_THEME" LIT_FONTS="$ORIGINAL_FONTS" trap 'rc=$?; cleanup || rc=1; guard_exit "$rc" || exit $?' EXIT connect "$ADMIN_LOGIN" # confirm : dry run must answer confirm_required; then the confirmed call; prints the result. confirm() { local dry rev dry=$(ability "$1" "$2") [ "$(error_code "$dry")" = "confirm_required" ] || fail "$1 dry run: $dry" rev=$(echo "$dry" | jq -r '.__error | fromjson | .design_rev') ability "$1" "$(echo "$2" | jq -c --arg r "$rev" '. + {confirm: true, design_rev: $r}')" } theme_option() { # $1 = key -> the stored value ('' when the key is not set) wp eval "\$o = get_option('et_divi'); echo array_key_exists('$1', \$o) ? var_export(\$o['$1'], true) : '';" } # 1. Read: the tool lists the options with effective values and a design_rev. GET=$(ability lit/divi-theme-options-get '{}') echo "$GET" | jq -e '.design_rev | type == "string"' >/dev/null || fail "theme-options-get without design_rev: $GET" echo "$GET" | jq -e '[.. | objects | select(has("effective"))] | length >= 20' >/dev/null \ || fail "theme-options-get does not list the 20 options with effective values: $GET" pass "theme options listed" # 2. Change two options (snapshot is taken), stale design_rev refused, values stored with Divi's types. UPDATE=$(jq -cn '{set: {body_font_size: 18, content_width: "1200"}}') DONE=$(confirm lit/divi-theme-options-update "$UPDATE") [ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "theme options update: $DONE" THEME_SNAPSHOT=$(echo "$DONE" | jq -r '.snapshot_id') [ "$(theme_option body_font_size)" = "18" ] || fail "body_font_size not stored" [ "$(theme_option content_width)" = "1200" ] || fail "content_width not stored as an integer" STALE=$(ability lit/divi-theme-options-update "$(echo "$UPDATE" | jq -c '. + {confirm: true, design_rev: "veraltet"}')") [ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale design_rev: $STALE" pass "theme options changed ($THEME_SNAPSHOT), stale design_rev refused" # 3. Validation: unknown font with suggestions, range, 'none', unknown key – all collected, nothing written. BAD=$(ability lit/divi-theme-options-update '{"set": {"heading_font": "Robotto", "body_font_size": 99, "body_font": "none", "colour": 1}}') [ "$(error_code "$BAD")" = "validation_failed" ] || fail "invalid input accepted: $BAD" echo "$BAD" | grep -q 'Roboto' || fail "no font suggestion in: $BAD" [ "$(theme_option body_font_size)" = "18" ] || fail "invalid call changed body_font_size" pass "unknown font, range, none and unknown key refused with suggestions" # 4. A known Google font is accepted; null resets an option to Divi's default (key removed). DONE=$(confirm lit/divi-theme-options-update '{"set": {"heading_font": "Roboto", "content_width": null}}') [ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "font + reset: $DONE" [ "$(theme_option heading_font)" = "'Roboto'" ] || fail "heading_font not stored" [ "$(theme_option content_width)" = "" ] || fail "content_width not removed by null" pass "Google font stored, null resets to Divi's default" # 5. Restore the snapshot of step 2: body_font_size and content_width are back as before step 2; heading_font too. LIST=$(ability lit/divi-design-snapshots '{"area": "theme_options"}') echo "$LIST" | jq -e --arg s "$THEME_SNAPSHOT" 'any(.snapshots[]; .id == $s)' >/dev/null || fail "snapshot not listed: $LIST" DONE=$(confirm lit/divi-design-restore "$(jq -cn --arg s "$THEME_SNAPSHOT" '{snapshot_id: $s}')") [ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "restore: $DONE" # The snapshot of step 2 was taken before any change of this run: the three keys must equal their original state # (present with the same typed value, or absent) – compared against the captured et_divi, not against assumptions. for KEY in body_font_size content_width heading_font; do LIT_KEY="$KEY" wp eval '$o = unserialize(base64_decode(getenv("LIT_THEME"))); $n = get_option("et_divi"); $k = getenv("LIT_KEY"); exit(array_key_exists($k, $o) === array_key_exists($k, $n) && ($o[$k] ?? null) === ($n[$k] ?? null) ? 0 : 1);' \ || fail "$KEY not restored to its state before the first change" done pass "theme_options snapshot restored (body_font_size, content_width, heading_font as before)" # 6. Font upload: Probelauf names the file (format, bytes, sha256) and installs nothing. UPLOAD=$(jq -cn --arg n "$FONT_NAME" --arg u "$FONT_URL" '{name: $n, files: {woff2: $u}, weights: "all"}') DRY=$(ability lit/divi-fonts-upload "$UPLOAD") [ "$(error_code "$DRY")" = "confirm_required" ] || fail "font dry run: $DRY" echo "$DRY" | grep -q 'woff2' || fail "dry run does not name the format: $DRY" LIT_FONT="$FONT_NAME" wp eval 'exit(isset(get_option("et_uploaded_fonts", [])[getenv("LIT_FONT")]) ? 1 : 0);' \ || fail "dry run installed the font" pass "font dry run installs nothing" # 7. Confirmed upload: the font is in et_uploaded_fonts, the file exists, the name is now reserved. DONE=$(ability lit/divi-fonts-upload "$(echo "$UPLOAD" | jq -c '. + {confirm: true}')") [ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "font upload: $DONE" LIT_FONT="$FONT_NAME" wp eval '$f = get_option("et_uploaded_fonts", [])[getenv("LIT_FONT")] ?? null; exit($f && isset($f["font_url"]["woff2"]) ? 0 : 1);' \ || fail "font not stored in et_uploaded_fonts" AGAIN=$(ability lit/divi-fonts-upload "$(echo "$UPLOAD" | jq -c '. + {confirm: true}')") [ "$(error_code "$AGAIN")" != "" ] || fail "a second upload of the same name was accepted: $AGAIN" pass "font installed through Divi, same name refused" # 8. The uploaded font is a valid theme font now; Arial (web-safe) is refused as a new font name. DONE=$(confirm lit/divi-theme-options-update "$(jq -cn --arg n "$FONT_NAME" '{set: {body_font: $n}}')") [ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "uploaded font as body_font: $DONE" RESERVED=$(ability lit/divi-fonts-upload "$(jq -cn --arg u "$FONT_URL" '{name: "Arial", files: {woff2: $u}, weights: "all"}')") [ "$(error_code "$RESERVED")" = "validation_failed" ] || fail "web-safe name accepted: $RESERVED" pass "uploaded font usable as theme font, web-safe name refused" # 9. A file whose content does not match its format key is refused (a PNG offered as woff2). WRONG=$(ability lit/divi-fonts-upload "$(jq -cn --arg n "$FONT_NAME Falsch" '{name: $n, files: {woff2: "https://s.w.org/images/wmark.png"}, weights: "all"}')") [ "$(error_code "$WRONG")" = "font_invalid" ] || fail "wrong content accepted: $WRONG" pass "content/format mismatch refused (font_invalid)" finish ``` - [ ] **Step 2: Syntax prüfen** Run: `bash -n tests/Integration/divi-theme-fonts-smoke.sh && (command -v shellcheck >/dev/null && shellcheck -x tests/Integration/divi-theme-fonts-smoke.sh || true)` Expected: keine Syntaxfehler; Warnungen zu `$`-Escapes in `wp eval`-Strings sind erwartet. - [ ] **Step 3: In DDEV ausführen und korrigieren** Vorbereitung: `cd /home/janniclas/Projekte/WordPress && ddev exec wp plugin list` notieren, dann `ddev exec wp plugin deactivate postlos asynclistry` (nur falls aktiv); nach den Läufen den notierten Zustand wiederherstellen. Run: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-theme-fonts-smoke.sh` Expected: `ALL CHECKS PASSED`. Schlägt eine Prüfung fehl, die Antwort der Ability lesen (`fail` druckt sie): Ein Skriptfehler (Feldname, Quoting, jq-Pfad) wird im Skript berichtigt – Prüfung bleibt; ein Produktfehler (das Tool tut gegen echtes Divi etwas Falsches) wird NICHT im Skript versteckt, sondern mit Diagnose (Antwort, erwartet/ist, Verdacht mit Datei:Zeile) berichtet (DONE_WITH_CONCERNS). Schritt 5 vergleicht die drei Schlüssel nach dem Restore mit dem erfassten Original (`LIT_THEME`); der Snapshot aus Schritt 2 entstand vor jeder Änderung des Laufs, deshalb müssen alle drei Schlüssel exakt (vorhanden mit gleichem getypten Wert oder fehlend) dem Original entsprechen. Danach prüfen, dass nichts übrig ist: `et_divi` und `et_uploaded_fonts` byte-identisch zum Zustand vor dem Lauf (`ddev exec wp eval 'echo md5(serialize(get_option("et_divi")));'` vor und nach dem Lauf), keine Schrift „LIT Smoke …“, keine Dateien `LIT-Smoke*` oder `*Roboto*` neu in `wp-content/uploads/et-fonts/`. Run zusätzlich: `divi-release-smoke.sh` (erwartet jetzt 16 Fähigkeiten), `divi-colors-variables-smoke.sh`, `divi-presets-smoke.sh`, `divi-presets-assign-smoke.sh` – alle `ALL CHECKS PASSED`; und `ddev exec wp --user= lit-mcp divi-check` mit Exit-Code 0 (alle Fähigkeiten nutzbar; die neuen Fingerabdrücke aus Task 2 und 8 sind schon committet). - [ ] **Step 4: Commit** ```bash git add tests/Integration/divi-theme-fonts-smoke.sh git commit -m "test: Smoke für Theme-Optionen und Schriften-Upload gegen Divi 5.15.1" -m "Co-Authored-By: Claude " ``` ### Task 11: Recherche ablegen, Notizen, Spec, Gesamtverifikation und Abschluss **Files:** - Create: `docs/superpowers/notes/2026-10-19-etappe-4d-divi-recherche.md` (die Divi-5.15.1-Recherche; Kopie der Planer-Notiz, redaktionell auf die Entscheidungen W1–W14 abgestimmt) - Modify: `docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md` (Nachtrag „Etappe 4d“ nach dem Nachtrag 4c) - Modify: `docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md` (§8.1, §8.2, §10) - Modify: `docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md` (Abschnitt „Etappe 4d“ mit den Rulings und den offenen Punkten) - Modify: `docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md` (Abschnitt „Offen“: Theme-Optionen-Fähigkeiten erledigt) - Modify: Memory `etappe-4-stand` (außerhalb des Repos, `/home/janniclas/.claude/projects/-home-janniclas-Projekte-WordPress-wp-content-plugins-WP-LIT-MCP/memory/etappe-4-stand.md`) **Interfaces:** - Consumes: alles aus den Tasks 1–10. - Produces: grüne Suite, grüne Smokes, Gesamtreview ohne offene wichtige Befunde, nachgeführte Spec und Notizen, aktualisierter Stand. - [ ] **Step 1: Recherche und Nachträge schreiben** 1. Die Recherche (`…/scratchpad/research-4d-divi.md`, falls die Datei fehlt: aus Plan und Code neu fassen) als `docs/superpowers/notes/2026-10-19-etappe-4d-divi-recherche.md` ablegen. 2. Formate-Nachtrag „Divi 5.15.1 – Theme-Optionen und Schriften (Etappe 4d, 2026-10-19)“ in `2026-09-28-etappe-4-divi-formate.md` nach dem Nachtrag 4c: die zwölf Befunde der Recherche (Abschnitt 0) in einer Liste, je ein Satz, mit Verweis auf die Recherche für Zeilen. 3. Spec: §8.2 – der Nachbau des `et_uploaded_fonts`-Eintrags („eigener Sideload, … Nachbau mit Fingerabdruck“) ersetzen durch „Installation über `FontsUtility::font_add()` im Prozess (`action: wp_handle_upload_rest`, wie Divis KI-Pfad), Mirror `font-install`“; Formate `woff2`, `woff`, `ttf`, `otf`; §8.1 – `body_header_style` nimmt einen Wert, `null` setzt auf den Divi-Standard zurück; §10 – Zeile „Farben, Presets schreiben, Restore, Fonts-Upload“ trennen: Fonts-Upload = `upload_files` + `edit_theme_options` + Divi-Recht `custom_fonts_management`; Theme-Optionen = `edit_theme_options` + `theme_customizer`. 4. Entscheidungsnotiz: Abschnitt „Etappe 4d (Plan 2026-10-19-etappe-4d-theme-optionen-schriften.md)“ mit W1–W14 in je einem Satz und der Liste der offenen Folgepunkte: Schriftprüfung in Preset-/Modul-Attributen (nicht Teil von 4d), `lit/divi-fonts-list`/Schriftsuche (Vorschläge nur in Fehlern), Entfernen hochgeladener Schriften (A2), `eot`. 5. `2026-10-18-divi-faehigkeiten.md` „Offen“: den Punkt zu `DesignArea::feature()` für Theme-Optionen als erledigt kennzeichnen (Fähigkeiten `theme-option-read`/`-write`, `font-upload`). - [ ] **Step 2: Suite, Lint, Divi-Prüfung** Run: `composer test && composer lint` Expected: alle Tests grün (3121 + die neuen), phpcs ohne Befund. Run: `cd /home/janniclas/Projekte/WordPress && ddev exec wp --user= lit-mcp divi-check` Expected: Exit-Code 0; die drei neuen Fähigkeiten „Theme-Optionen lesen“, „Theme-Optionen schreiben“, „Schriften hochladen“ erscheinen als nutzbar. - [ ] **Step 3: Gesamtreview (zwei unabhängige Opus-Reviews parallel)** Dispatch (zwei Subagents, Modell Opus; je mit dem Paket `scripts/review-package PLAN BASE HEAD` über den ganzen Plan und den Rulings W1–W14): 1. Fachreview: Korrektheit gegen Plan und Spec, Sicherheit (SSRF-Pfad über `RemoteFileFetcher`, Dateityp/Magic Bytes, Größenlimit, Temp-Dateien immer gelöscht, Rechte in `permission()` UND `execute()`, Restore mit unlesbarer Snapshot-ID, `unset` statt `'none'`, Teilerfolg von `font_add`, `et_update_option`-Lost-Update, Cache-Aufrufe ohne angemeldeten Benutzer), Divi-Grenze und Mirror-Fingerabdrücke, Fähigkeitszuordnung je Tool. 2. `anthropic-skills:clean-code-review` (Quick) über die neuen und geänderten Klassen; bekannte Folgepunkte aus 4a–4c nur nennen, wenn 4d sie verschlimmert. Wichtige und größere Befunde in EINER Fix-Welle (Sonnet) beheben, danach EIN scoped Re-Review (Sonnet); Kleinigkeiten in die Ledger-Notiz. - [ ] **Step 4: Memory nachführen** `etappe-4-stand.md` und `MEMORY.md` aktualisieren: 4d fertig (HEAD, Testzahl, Datum), nächster Schritt Plan 4e (Schleifen-Inhalte G12, Guides `divi-design-system`/`divi-shortform`/`workflows`, Security-Audit, Version); aus 4d offene Punkte anhängen. - [ ] **Step 5: Abschluss** `git status` sauber; nichts pushen oder mergen ohne Auftrag. Dem Nutzer berichten: Testzahl, Smoke-Ergebnisse, Befunde und Rulings, und fragen, ob gemergt/gepusht werden soll. --- ## Anhang: Abweichungen der Task-Autoren vom Schnittstellenvertrag (geprüft, im Plan berücksichtigt) Die Autoren haben ihre Tasks in Kopien des Repos mit Stand-ins für fremde Tasks ausgeführt. Verbindlich bleibt der Taskinhalt; bei Zählungen (Tests, Adapter, Fingerabdrücke) gilt beim Umsetzen der echte Stand. ### Tasks 1-2 - **B1 (Ergänzung des Vertrags):** `ThemeOptionsAdapter` bekommt zusätzlich `public const SANITIZERS` (Whitelist-Schlüssel in W2-Reihenfolge => Name des Divi-/WordPress-Sanitizers). Grund: Task 2 kommt vor `ThemeOptionSpec` (Task 3), `read()`/`write()` brauchen die Whitelist, und die Zuordnung Schlüssel → Sanitizer ist Divi-Wissen, das an die Divi-Grenze gehört (der Vertrag gibt `ThemeOptionSpec::definition()` kein Sanitizer-Feld). Damit stehen die 20 Schlüssel zweimal (`SANITIZERS`, `ThemeOptionSpec::KEYS`); Task 3 sollte in `ThemeOptionSpecTest` `self::assertSame(ThemeOptionSpec::KEYS, array_keys(ThemeOptionsAdapter::SANITIZERS))` aufnehmen (die Konstante zu lesen ruft kein Divi; `ThemeOptionSpec` selbst bleibt rein). - **B2 (Signatur beibehalten, Parameter ungenutzt):** `DesignRights::themeOptions(DesignSystemWriter $writer, ThemeOptionsAdapter $options)` behält die Vertrags-Signatur, fragt `$writer` aber nicht: die Rechte für Theme-Optionen sind laut Global Constraints `edit_theme_options` + `et_pb_is_allowed('theme_customizer')`, kein Visual Builder (Divis Customizer fragt ihn nicht). Der Test pinnt das (`canUseVisualBuilder` `never()`). Den Parameter zu streichen hätte die Aufrufe der parallel geplanten Tasks 4/6 gebrochen; Task 11 (Gesamtreview) kann ihn samt Aufrufern entfernen. - **B3 (zusätzliche Testdatei):** `tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php` ist nicht in der Dateistruktur des Plans genannt, muss aber mit dem neuen Nachbau angepasst werden (Temp-Theme-Stand-in für `functions.php`, „7 Fingerabdrücke“) – Step 9. - **B4 (Tests ohne Mockery-Alias):** Die Adapter-Tests brauchen kein `Mockery::mock('alias:…')`: alle Divi-Symbole sind Funktionen (Brain Monkey in eigenem Prozess) bzw. der vorhandene Stub `ET_Core_PageResource`. Die Global Constraint „Adapter-Tests ersetzen Divi-Klassen per Alias“ greift erst bei `FontInstaller` (Task 8, `FontsUtility`/`HooksRegistration`). - **B5 (Grenzen für Task 11/Notiz):** `FontCatalog::reserved()` enthält Google-Schriften nur, wenn Google Fonts eingeschaltet sind (`et_builder_get_google_fonts()` liefert sonst `[]`, Divis `font_add` prüft ebenso) – ein Upload namens „Roboto“ ist bei abgeschalteten Google Fonts möglich. `names()` ist je Instanz gecacht; eine im selben Request hochgeladene Schrift ist darin erst mit einer neuen Instanz sichtbar (Upload und Schriftprüfung laufen in getrennten Requests). ### Tasks 3 - **`check()` lehnt `none` (ohne Beachtung der Schreibweise) bei Schrift-Schlüsseln ab.** Der Auftrag nennt es nicht, W3 verlangt aber „`'none'` und `false` werden abgelehnt“; `check()` ist die einzige Stelle ohne Divi-Aufruf, die das kann. Meldung: „… `none` ist kein Schriftname; null setzt auf Divis Standardschrift zurück.“ Außerdem wird ein Schriftname vor der Prüfung getrimmt (`" Lato "` ist gültig), `normalize()` liefert den getrimmten Namen. - **`definition()` enthält für `tablet_*`/`phone_*` den Standard 14 bzw. 30** (was Divis CSS-Leser bei gar nichts Gespeichertem annehmen) plus `inherits`; der Standard des Customizers ist dynamisch (`et_get_option('body_font_size', '14')`). `effective()` nutzt `inherits` zuerst, der feste Standard greift nur am Ende der Kette. `body_header_style` und die vier Schriften haben Standard `null` (Divi: `''` bzw. `false`/`'none'`), `heading_font_weight`/`body_font_weight` `'500'` als String, `body_header_height` `1.0` (float). - **Schrift-Stärke (`weight`)** akzeptiert in `check()` auch eine Ganzzahl (`500`), `normalize()` liefert immer den String (`'500'`), weil Divi so speichert. Ein Float (`500.0`) wird abgelehnt. - **`effective()` wertet einen gespeicherten Wert nicht gegen die Bereiche** (Divi klemmt auch nicht; ein alter Wert 40 für `body_font_size` bleibt 40) und behandelt `false`, `null`, `'none'`, leere Namen und Nicht-Zahlen als „nicht gesetzt“; gespeicherte Ziffern-Strings werden in den Typ des Schlüssels gewandelt. Task 4 darf deshalb `effective()` direkt auf das Ergebnis von `ThemeOptionsAdapter::read()` anwenden. - **`ThemeOptionBook::with()` wirft `InvalidArgumentException`** bei einem Schlüssel außerhalb der Whitelist (Task 4 prüft vorher mit `isKey()`); `fromDivi()` ignoriert sie dagegen stillschweigend (defensiv, Auftrag). `with($key, false)` ist erlaubt (Divis Standardschrift im Snapshot). - **`FontSuggest` ist bytebasiert beim Abstand** (`levenshtein` kennt kein UTF-8): bei Umlauten zählt ein Zeichen als zwei Abstandseinheiten; die Rangfolge Präfix/Teilstring nutzt `mb_*` und ist unberührt. Die Eingabeliste wird dedupliziert, der Name getrimmt; leerer Name oder `$limit < 1` ergeben `[]`. - Keine Konflikte mit Divi gefunden: alle geprüften Bereiche, Schritte und Standards (Zeilen 1903–2561 von `functions.php`) stimmen mit Tabelle A überein. Risiko: Bereiche und Standards sind Literale – ändert Divi sie, schlägt nur der Mirror `customizer-typography` (Task 2) an, nicht dieser Test. ### Tasks 4-6 1. **`DiviThemeOptionsGetAbility::__construct(ThemeOptionsAdapter $options)` – ohne `?FontCatalog`** (Vertrag: `(ThemeOptionsAdapter, ?FontCatalog = null)`). Das Lesetool braucht die Schriftliste nicht. Sie kostet 1,3 MB, und ihre Aufrufe tragen laut W1 `theme-option-write`. Mit ihr bräuchte das Lesetool also eine Schreibfähigkeit. Task 9 verdrahtet mit einem Argument. 2. **Neue Klasse `FontCheck`** (nicht in der Dateistruktur): Sie hält die gemeinsame Meldung „unbekannte Schrift … ähnlich: …“ für Theme-Optionen (Task 4) und Schrift-Variablen (Task 5), damit die Meldung nicht doppelt gepflegt wird. Sie ist rein bis auf die übergebenen `FontCatalog`-Methoden. 3. **`ThemeOptionsArea` hat zusätzlich `public const SITE_WIDE` und `public static same()`.** Beide sind gemeinsame Bausteine für das Update-Tool und den Restore-Planer. 4. **Reihenfolge der Prüfung bei den vier Schriftschlüsseln:** erst `ThemeOptionSpec::check`, dann `FontCatalog` mit Vorschlägen, dann Divis Sanitizer. Der Auftrag nannte den Sanitizer vor der Schriftliste. `et_sanitize_font_choices` liefert bei unbekannten Namen aber nur `false`, und die Vorschläge kämen dann nie. 5. **`'none'`/`false` lehnt das Update-Tool selbst ab**, vor `ThemeOptionSpec::check`, mit dem Hinweis auf `null`. Lehnt auch `check()` (Task 3) sie ab, gewinnt die Meldung des Tools. Das ist kein Konflikt. 6. **Annahmen an Task 3, auf die sich die Tests stützen:** - `ThemeOptionBook::fromDivi()` und `ThemeOptionDiff::between()` liefern Schlüssel bzw. Zeilen in der Reihenfolge von `ThemeOptionSpec::KEYS`. Die Diff-Reihenfolge legt der Vertrag nicht fest, die Tests vergleichen sie aber. - `definition()['type']` ist bei den Größen `'int'`, und `min`/`max` von `body_font_size` sind `10`/`32` (int). - `check()` lehnt `'bold'` (Stil), `99` (Textgröße) und `'breit'` (Spaltenabstand) ab und nimmt `'18'` an. - `effective()` vererbt W3-gemäß: Ist `tablet_body_font_size` nicht gesetzt, gilt der wirksame `body_font_size`. Weicht Task 3 ab, gehören die Fixtures angepasst, nicht der Code. 7. **Annahmen an Task 2:** - `DesignRights::themeOptions()` fragt `current_user_can('edit_theme_options')` und `ThemeOptionsAdapter::canCustomize()`. Die Test-Fakes erlauben zusätzlich `DesignSystemWriter::canUseVisualBuilder()` (→ true). Nutzt Task 2 weitere Methoden, braucht der Fake ein `allows()`. - `ThemeOptionsAdapter::write()` liefert das Zurückgelesene. Seine eigene strenge Prüfung (RuntimeException) bleibt. `ThemeOptionsArea::verify()` ist die zweite Prüfung, auch für Restore und Rollback. - Für die minimale Änderung in `DiviIntegration` (Task 5) nehme ich an, dass `FontCatalog` sich ohne Argumente bauen lässt. Hat sein Konstruktor Parameter, gelten dort die Argumente aus Task 2. 8. **`ThemeOptionsRestorePlanner::__construct(DesignSystemWriter, ThemeOptionsAdapter)`.** Diese Signatur legt der Vertrag nicht fest. Sie folgt dem Muster der Farb-/Variablen-Planer, und Task 9 verdrahtet sie. 9. **Hauptfarben in `theme-options-get`:** Es gibt nur einen Hinweis in `notes`, keine Werte. Das Tool braucht dafür also keinen neuen Leser im Adapter. ### Tasks 7-8 - **D1 `RemoteFileFetcher`-Schnittstelle:** `allows(string): bool` und `fetch(…): ?array` (`null` = fehlgeschlagen, Grund nur im Log) statt einer Methode mit `WP_Error`. Grund: die Meldungen an den Client unterscheiden sich (Medien `validation_failed` mit `url` wie bisher, Schriften `validation_failed`/`font_fetch_failed` mit `files.`); die `Content-Length`-Vorabprüfung bleibt medienbezogen in `MediaImporter` (Schriften prüfen die Dateigröße nach dem Abschneiden bei Limit + 1). `MediaImporter` behält seinen Konstruktor und erzeugt den Fetcher selbst – keine Verdrahtungsänderung, `MediaImporterTest` unverändert. - **D2 `FontNameRules::check()` hat einen optionalen dritten Parameter `int $maxLength = 100`;** die Ability übergibt `FontInstaller::nameMaxLength()` (= `FontsUtility::FONT_NAME_MAX_LENGTH`), so wird die Divi-Konstante wirklich gelesen und nicht nur im Manifest genannt. Zusätzlich `FontNameRules::taken()` (öffentlich), damit die Ability einen belegten Namen als `conflict` (409) statt als Validierungsfehler meldet; `check()` mit gefülltem `$reserved` meldet die Kollision weiterhin als Text (Vertrag). - **D3 `FontInstaller` zusätzlich:** `public const FORMATS` und `nameMaxLength()`. `formats()` schneidet `FORMATS` mit `FontsUtility::mime_types_font()` (W8), **nicht** mit `et_pb_get_supported_font_formats()` (Standard nur ttf/otf) – woff/woff2 sind nach W8 gewollt; das Manifest nennt diese Funktion deshalb nicht. - **D4 Teilerfolg:** `install()` entfernt bei Teilerfolg nicht selbst, sondern liefert die gespeicherten Endungen (Vertrag `@return list installierte Endungen`); die Ability vergleicht und ruft `remove()` über `Rollback::attempt()` (W8: „Abweichung → font_remove und Fehler“). Wirft `install()`, ist nichts gespeichert (Divi speichert erst nach mindestens einer erfolgreichen Datei, Vorprüfung aller Dateien vor `font_add`) – dann kein `remove()`. - **D5 `DiviCheckCommandTest` ist betroffen** (nicht im Dateiplan genannt): der neue Mirror braucht eine Minimal-Quelle im Wegwerf-Theme, die Zahl der Fingerabdrücke steigt. Fügt Task 2 den Mirror `customizer-typography` hinzu, ändert Task 2 dieselbe Stelle; Step 12 rechnet relativ dazu. - **D6 Fehlercodes:** `font_fetch_failed` und `font_invalid` mit Status 400 und `fields`; hat ein Aufruf beide Arten, gilt `font_fetch_failed` mit allen Feldern. Größenüberschreitung ist `font_invalid` („größer als N MB“). - **D7 Protokoll-Objekttyp `divi_font`:** `src/Admin/LogObjects.php` kennt ihn nicht und zeigt ihn als Code (`divi_font Inter Display`); ein Label kann Task 11 ergänzen. - **Prüflauf:** im Arbeitsstand des Planers mit Platzhaltern für `DiviFeature::FontUpload` (inkl. angepasstem `DiviFeatureTest`), `FontCatalog` (ohne Divi) und `DesignRights::fonts()`; `fingerprints.json` und `font-install.txt` dort aus den Divi-5.15.1-Quellen mit `DiviReference`/`SourceFingerprint` erzeugt (nicht über DDEV). Die RED/GREEN-Zahlen oben stammen aus diesen Läufen.