Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
7384 lines
372 KiB
Markdown
7384 lines
372 KiB
Markdown
# 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/<name>` (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/<skript>.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 "<Nachricht>" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"` 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<string, int|float|string|false>`); 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<string, int|float|string|false> 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<string, int|float|string|false> $set @param list<string> $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<string> exakte Familiennamen: hochgeladene + Google + Web-Safe, einmal je Request */
|
||
public function names(): array;
|
||
/** @return list<string> */ public function uploaded(): array;
|
||
/** @return list<string> 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<string> 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<string, string> $files Endung => Temp-Dateipfad @return list<string> 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<string>, 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<string, int|float|string|false> */ 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<array{key: string, label: string, action: string, before: mixed, after: mixed}> 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<string> $names @return list<string> 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
|
||
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class DiviFeatureTest extends TestCase
|
||
{
|
||
public function testTheSixteenFeaturesAndTheirValues(): void
|
||
{
|
||
self::assertSame(
|
||
[
|
||
'PageRead' => '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-<name>, indirect lit/<name>): $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[<key>]`, 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('<Divi>/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(<name>, 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<string, int|float|string|false>` – 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: <key>')`. `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: <keys>')`; 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<string>` – Schlüssel von `et_builder_get_custom_fonts()` dann `et_builder_get_fonts()` (genau Divis Sanitizer-Liste), je Instanz einmal geladen; `uploaded(): list<string>` und `reserved(): list<string>` (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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||
use LIT\MCP\Tests\Support\ReleaseMatrices;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use RuntimeException;
|
||
|
||
/**
|
||
* Divi's sanitizers, et_update_option(), et_delete_option(), et_pb_force_regenerate_templates() and
|
||
* et_pb_is_allowed() are faked with Brain Monkey; those tests run in their own process (Brain Monkey defines a missing
|
||
* function for the rest of the process, and other tests rely on Divi's functions being absent). ET_Core_PageResource
|
||
* is the recording stub of tests/Stubs.
|
||
*/
|
||
final class ThemeOptionsAdapterTest extends TestCase
|
||
{
|
||
/** Plan 4d W2, in this order. */
|
||
private const KEYS = [
|
||
'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',
|
||
];
|
||
|
||
public function testIsARegisteredDesignAdapterForThemeOptions(): void
|
||
{
|
||
$manifest = ThemeOptionsAdapter::manifest();
|
||
|
||
self::assertContains(ThemeOptionsAdapter::class, DiviAdapters::CLASSES);
|
||
self::assertSame(Manifest::DESIGN, $manifest->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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
/**
|
||
* Divi's font lists and et_pb_is_allowed() are faked with Brain Monkey; those tests run in their own process (Brain
|
||
* Monkey defines a missing function for the rest of the process, and other tests rely on Divi's functions being
|
||
* absent).
|
||
*/
|
||
final class FontCatalogTest extends TestCase
|
||
{
|
||
public function testIsARegisteredDesignAdapterForTheFontLists(): void
|
||
{
|
||
$manifest = FontCatalog::manifest();
|
||
|
||
self::assertContains(FontCatalog::class, DiviAdapters::CLASSES);
|
||
self::assertSame(Manifest::DESIGN, $manifest->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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
use LIT\MCP\Integrations\Divi\Release\Mirror;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use RuntimeException;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Adapter "Designsystem" (spec §3) for the whitelisted theme options of Divi's customizer (plan 4d W2, W6): reads
|
||
* them raw from get_option('et_divi'), sends a value through the customizer's own sanitizer of its setting, and
|
||
* writes the way a customizer save ends – et_update_option()/et_delete_option() per key, then the static CSS and the
|
||
* builder caches (research A.2/A.3). Limits (W13): et_update_option() writes the whole et_divi array from Divi's
|
||
* request global, so a change another process made after this request loaded it is lost; design_rev and the lock of
|
||
* the pipeline narrow that window. remove_static_resources() does nothing without a logged-in user. Not final so the
|
||
* theme option area can be tested with a mock.
|
||
*/
|
||
class ThemeOptionsAdapter implements DiviAdapter
|
||
{
|
||
/** Divi's theme options (one array option). */
|
||
private const OPTIONS = 'et_divi';
|
||
|
||
/**
|
||
* The 20 whitelist keys (plan 4d W2, in this order) => 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<string, int|float|string|false>
|
||
*/
|
||
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<string, int|float|string|false> $set Checked values (ThemeOptionSpec, sanitize()) or raw values
|
||
* of a snapshot.
|
||
* @param list<string> $unset
|
||
* @return array<string, int|float|string|false> 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Adapter "Designsystem" (spec §3) for Divi's font lists (plan 4d W5, W9): the families Divi's customizer sanitizer
|
||
* accepts (uploaded fonts + et_builder_get_fonts(), exact names), the uploaded ones, and every reserved name for an
|
||
* upload, plus Divi's role right "Eigene Schriften verwalten". et_builder_get_google_fonts() parses about 1.3 MB and
|
||
* may fetch from Google once a day, so names() is loaded once per instance – DiviIntegration builds one per request;
|
||
* uploaded() and reserved() are read fresh (an upload changes them). Not final so the tools can be tested with a mock.
|
||
*/
|
||
class FontCatalog implements DiviAdapter
|
||
{
|
||
/** @var list<string>|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<string> 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<string> */
|
||
public function uploaded(): array
|
||
{
|
||
return self::namesOf(et_builder_get_custom_fonts());
|
||
}
|
||
|
||
/** @return list<string> 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<string>
|
||
*/
|
||
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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Who may change the design system (spec §10): presets and colors need edit_theme_options and Divi's role right
|
||
* "Visual Builder verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1"); variables
|
||
* also the role right "Variables Manager". Theme options need edit_theme_options and the role right
|
||
* "Theme-Anpassung" (what Divi's customizer asks), font uploads upload_files, edit_theme_options and the role right
|
||
* "Eigene Schriften verwalten" (Divi's font route plus the plugin's rule, plan 4d). Without unfiltered_html a write is
|
||
* refused while presets it saves along hold HTML/CSS that Divi would strip (final review I-2).
|
||
*/
|
||
final class DesignRights
|
||
{
|
||
private const ROLE_EDITOR = 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.';
|
||
|
||
public const OTHER_MARKUP = 'Andere Presets enthalten HTML/CSS, das beim Speichern ohne das Recht unfiltered_html '
|
||
. 'verloren ginge; nichts wurde gespeichert.';
|
||
|
||
/** @return Closure(): bool Whether the current user may save unfiltered HTML. */
|
||
public static function unfilteredHtml(): Closure
|
||
{
|
||
return static fn (): bool => 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<string> $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:<name>": "<sha256>"`); `?? 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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
|
||
### 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<string>, 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class ThemeOptionSpecTest extends TestCase
|
||
{
|
||
public function testTheTwentyKeysKeepTheOrderOfRulingW2(): void
|
||
{
|
||
self::assertSame([
|
||
'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',
|
||
], ThemeOptionSpec::KEYS);
|
||
}
|
||
|
||
/** @return array<string, array{string, string, int|float|string|null, int|float|null, int|float|null}> */
|
||
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<string, array{string, mixed}> */
|
||
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<string, array{string, mixed, string}> */
|
||
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<Sans>', '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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The 20 theme options the plugin manages (ruling W2) with what Divi's customizer says about them
|
||
* (et_divi_customizer_theme_settings(), Divi 5.15.1): type, default, range, choices, inheritance. Divi's own
|
||
* sanitizers do not clamp (ruling W4), so check() is where min/max/step/choices are enforced. The font list is
|
||
* not checked here (FontCatalog does). Pure PHP – a copy of the customizer literals, guarded by the mirror
|
||
* "customizer-typography".
|
||
*/
|
||
final class ThemeOptionSpec
|
||
{
|
||
/** The whitelist in the order of ruling W2; books, diffs and the tool output use it. */
|
||
public const KEYS = [
|
||
'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',
|
||
];
|
||
|
||
private const MAX_FONT_LENGTH = 100;
|
||
|
||
private const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900'];
|
||
|
||
private const STYLES = ['italic', 'uppercase', 'underline'];
|
||
|
||
private const BODY_SIZE = ['type' => '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<string>, 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<string, mixed> $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<string, mixed> $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<string> $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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class ThemeOptionBookTest extends TestCase
|
||
{
|
||
/** @return array<string, mixed> 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Abilities\Rev;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The managed theme options as stored in get_option('et_divi') (ruling W6): only the whitelist keys that are
|
||
* set, in the fixed order of ThemeOptionSpec::KEYS, with the raw value and type (int, float, string or false –
|
||
* Divi stores its default font as false). A key that is not set is missing. Takes a snapshot or the whole et_divi
|
||
* array alike. Immutable – with() and without() return a new book. rev() is the design_rev of the area
|
||
* "theme_options". Pure PHP.
|
||
*/
|
||
final class ThemeOptionBook
|
||
{
|
||
/** 1.0 and 1 are different stored values, so the hash must tell them apart (zero fraction kept). */
|
||
private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION;
|
||
|
||
/** @param array<string, int|float|string|false> $data */
|
||
private function __construct(private array $data)
|
||
{
|
||
}
|
||
|
||
/** @param array<array-key, mixed> $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<string, int|float|string|false> */
|
||
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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionDiff;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class ThemeOptionDiffTest extends TestCase
|
||
{
|
||
public function testNoChangeIsAnEmptyDiffEvenWhenTheOrderOfTheInputDiffers(): void
|
||
{
|
||
$book = ThemeOptionBook::fromDivi(ThemeOptionBookTest::stored());
|
||
$same = ThemeOptionBook::fromDivi(array_reverse(ThemeOptionBookTest::stored(), true));
|
||
|
||
self::assertSame([], ThemeOptionDiff::between($book, $same));
|
||
self::assertSame([], ThemeOptionDiff::between(ThemeOptionBook::fromDivi([]), ThemeOptionBook::fromDivi([])));
|
||
}
|
||
|
||
public function testAddedChangedAndResetInTheOrderOfTheKeys(): 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([
|
||
['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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* What a theme-options change does (spec §4 step 4, "diff"): per key added (not set before), changed or reset
|
||
* (removed afterwards – Divi falls back to its default). Values are compared strictly, so false (Divi's default
|
||
* font) and a type change (1 versus "1") count. before/after carry the raw values, null where the key is not set.
|
||
* In the order of ThemeOptionSpec::KEYS. Pure PHP.
|
||
*/
|
||
final class ThemeOptionDiff
|
||
{
|
||
/**
|
||
* @return list<array{key: string, label: string, action: string, before: mixed, after: mixed}>
|
||
* 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<array<string, mixed>> $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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\FontSuggest;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class FontSuggestTest extends TestCase
|
||
{
|
||
private const NAMES = [
|
||
'Roboto', 'Roboto Condensed', 'Roboto Slab', 'Open Sans', 'Open Sans Condensed', 'Lato', 'Lora', 'Arial',
|
||
'Source Sans 3', 'Playfair Display', 'Helvetica', 'Georgia',
|
||
];
|
||
|
||
public function testTheSameNameInAnotherCaseComesFirst(): void
|
||
{
|
||
self::assertSame(
|
||
['Open Sans', 'Open Sans Condensed'],
|
||
FontSuggest::similar('open sans', self::NAMES, 2)
|
||
);
|
||
self::assertSame('Lato', FontSuggest::similar('LATO', self::NAMES)[0]);
|
||
}
|
||
|
||
public function testRanksPrefixBeforeSubstringBeforeASmallDistance(): void
|
||
{
|
||
$names = ['Big Roboto', 'Rubik', 'Roboto Slab', 'Roboot', 'Robot'];
|
||
|
||
self::assertSame(
|
||
['Roboto Slab', 'Big Roboto', 'Robot', 'Roboot'],
|
||
FontSuggest::similar('roboto', $names)
|
||
);
|
||
}
|
||
|
||
public function testATypoIsFoundByDistanceButUnrelatedNamesAreNot(): void
|
||
{
|
||
self::assertSame(['Open Sans'], FontSuggest::similar('Opne Sans', self::NAMES));
|
||
self::assertSame(['Playfair Display'], FontSuggest::similar('Playfair Displey', self::NAMES));
|
||
self::assertSame([], FontSuggest::similar('Comic Sans', ['Arial', 'Georgia', 'Helvetica']));
|
||
}
|
||
|
||
public function testTheLimitCutsTheListAndTheOrderIsStable(): void
|
||
{
|
||
$first = FontSuggest::similar('Roboto', self::NAMES, 2);
|
||
|
||
self::assertSame(['Roboto', 'Roboto Slab'], $first);
|
||
self::assertSame($first, FontSuggest::similar('Roboto', array_reverse(self::NAMES), 2));
|
||
self::assertSame(['Roboto', 'Roboto Slab', 'Roboto Condensed'], FontSuggest::similar('Roboto', self::NAMES, 5));
|
||
self::assertCount(5, FontSuggest::similar('o', self::NAMES));
|
||
}
|
||
|
||
public function testTiesAreBrokenByLengthThenAlphabet(): void
|
||
{
|
||
self::assertSame(['Aab', 'Aac', 'Aaaa'], FontSuggest::similar('aa', ['Aaaa', 'Aac', 'Aab']));
|
||
}
|
||
|
||
public function testEdgeCases(): void
|
||
{
|
||
self::assertSame([], FontSuggest::similar('', self::NAMES));
|
||
self::assertSame([], FontSuggest::similar(' ', self::NAMES));
|
||
self::assertSame([], FontSuggest::similar('Lato', self::NAMES, 0));
|
||
self::assertSame([], FontSuggest::similar('Lato', []));
|
||
self::assertSame(['Lato'], FontSuggest::similar(' lato ', ['Lato', 'Lato']), 'trimmed, duplicates once');
|
||
self::assertSame(['Fjalla Önö'], FontSuggest::similar('FJALLA ÖNÖ', ['Fjalla Önö']));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3d.2: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/FontSuggestTest.php`
|
||
Expected: FAIL – `Tests: 6, Assertions: 0, Errors: 6`; jeder Fehler ist `Error: Class "LIT\MCP\Integrations\Divi\Design\FontSuggest" not found`.
|
||
|
||
- [ ] **Step 3d.3: `FontSuggest` implementieren**
|
||
|
||
Datei `src/Integrations/Divi/Design/FontSuggest.php` anlegen:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Names that resemble an unknown font name, for the hint of a validation error (ruling W5). Case-insensitive;
|
||
* ranked: the same name in another case, a name that starts with it, one that contains it, then a small
|
||
* Levenshtein distance (at most max(3, length / 3)). Within a rank the closer, then the shorter, then the
|
||
* alphabetically first name wins, so the order is deterministic. Pure PHP.
|
||
*/
|
||
final class FontSuggest
|
||
{
|
||
/**
|
||
* @param list<string> $names
|
||
* @return list<string> 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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 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<string> $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: {<key>: Wert|null, …}, confirm?, design_rev?}`. `set` muss ein nicht leeres Objekt sein. Gesammelt mit Pfad `set.<key>`: 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsArea;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class ThemeOptionsAreaTest extends TestCase
|
||
{
|
||
/**
|
||
* ThemeOptionsAdapter::read(): body font, text size and line height set; everything else at Divi's default.
|
||
*
|
||
* @return array<string, int|float|string|false>
|
||
*/
|
||
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<string, int|float|string|false> $stored
|
||
* @param list<array{0: array<string, int|float|string|false>, 1: list<string>}> $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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The area "theme_options" (spec §8.1, rulings W2, W3, W6): the 20 whitelisted Divi theme options as et_divi stores
|
||
* them (ThemeOptionBook); a key missing from the inventory is unset, i.e. at Divi's default. Every write replaces the
|
||
* whole whitelist through ThemeOptionsAdapter::write() – keys of the inventory are set, whitelist keys missing from it
|
||
* are deleted – so a change, a restore and a rollback all leave exactly the given inventory. verify() insists on the
|
||
* same keys with the same values and only tolerates a number stored as another type (15, "15", 15.0);
|
||
* differences() names those.
|
||
*/
|
||
final class ThemeOptionsArea implements DesignArea
|
||
{
|
||
/** Ruling W7: theme options have no page reference; every plan warns about their reach instead. */
|
||
public const SITE_WIDE = 'Theme-Optionen wirken auf die gesamte Website.';
|
||
|
||
public function __construct(private ThemeOptionsAdapter $options)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'theme_options';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Theme-Optionen';
|
||
}
|
||
|
||
public function feature(): DiviFeature
|
||
{
|
||
return DiviFeature::ThemeOptionWrite;
|
||
}
|
||
|
||
public function raw(): array
|
||
{
|
||
return ThemeOptionBook::fromDivi($this->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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Integrations\Divi\Design\DiviThemeOptionsGetAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class DiviThemeOptionsGetAbilityTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
}
|
||
|
||
private static function ability(): DiviThemeOptionsGetAbility
|
||
{
|
||
$stored = ThemeOptionsAreaTest::stored();
|
||
$writes = [];
|
||
|
||
return new DiviThemeOptionsGetAbility(ThemeOptionsAreaTest::adapter($stored, $writes));
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $result
|
||
* @return array<string, array<string, mixed>> 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/divi-theme-options-get (spec §8.1, rulings W2, W3): the 20 whitelisted Divi theme options – per key the stored
|
||
* value (null = unset), the value in effect (an unset tablet/phone size inherits the larger size), Divi's default,
|
||
* type, range or choices – and the design_rev of the area. Reading needs what lit/divi-design-system needs.
|
||
*/
|
||
final class DiviThemeOptionsGetAbility implements Ability, RequiresDiviFeatures
|
||
{
|
||
/** Fields of ThemeOptionSpec::definition() passed through as they are. */
|
||
private const RULES = ['min', 'max', 'step', 'choices', 'inherits'];
|
||
|
||
private const NOTES = [
|
||
'Ändern mit lit/divi-theme-options-update: set {Schlüssel: Wert}; null setzt auf den Divi-Standard zurück.',
|
||
'Ohne eigenen Wert erben Tablet- und Smartphone-Größen die nächstgrößere Größe (inherits).',
|
||
'Die fünf Hauptfarben ändert lit/divi-colors-set; Buttons, Logo und SEO bleiben im Divi-Customizer.',
|
||
];
|
||
|
||
public function __construct(private ThemeOptionsAdapter $options)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/divi-theme-options-get';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Divi-Theme-Optionen lesen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Liest Divis Theme-Optionen für Schriften, Schriftgrößen und Layout: je Schlüssel den gespeicherten '
|
||
. 'Wert (null = nicht gesetzt), den wirksamen Wert, den Divi-Standard, Typ und Bereich oder Auswahl, dazu '
|
||
. 'die design_rev. keys grenzt auf einzelne Schlüssel ein. Ändern mit lit/divi-theme-options-update.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => '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<string, mixed> $input
|
||
* @return list<string>|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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
|
||
use LIT\MCP\Integrations\Divi\Design\FontCheck;
|
||
use LIT\MCP\Integrations\Divi\Design\FontSuggest;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use Mockery\MockInterface;
|
||
|
||
final class FontCheckTest extends TestCase
|
||
{
|
||
/** The names FontCatalog::names() delivers in these tests. */
|
||
public const NAMES = ['Lato', 'Open Sans', 'Roboto', 'Roboto Mono', 'Roboto Slab'];
|
||
|
||
/** A FontCatalog over NAMES: has() is exact, like Divi's sanitizer (ruling W5). */
|
||
public static function catalog(): FontCatalog&MockInterface
|
||
{
|
||
$fonts = Mockery::mock(FontCatalog::class);
|
||
$fonts->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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use Closure;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\DiviThemeOptionsUpdateAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionDiff;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsArea;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use RuntimeException;
|
||
|
||
final class DiviThemeOptionsUpdateAbilityTest extends TestCase
|
||
{
|
||
/** @var array<string, int|float|string|false> What the adapter stores (ThemeOptionsAreaTest::adapter()). */
|
||
private array $stored = [];
|
||
|
||
/** @var list<array{0: array<string, int|float|string|false>, 1: list<string>}> */
|
||
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<string, mixed> $input
|
||
* @return array{0: array<string, mixed>, 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Ruling W5: a font name is valid when Divi knows it exactly (FontCatalog); otherwise the message names up to five
|
||
* similar fonts (FontSuggest) or the upload tool. Shared by the theme options and the font variables (W12).
|
||
*/
|
||
final class FontCheck
|
||
{
|
||
/** @return string|null Null when $name is a font Divi knows, else a German message. */
|
||
public static function problem(FontCatalog $fonts, string $name): ?string
|
||
{
|
||
if ($fonts->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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/divi-theme-options-update (spec §8.1, rulings W2–W5, W7): sets or resets the whitelisted Divi theme options
|
||
* through the change pipeline (ThemeOptionsArea). set maps keys to values; null resets a key to Divi's default
|
||
* (et_delete_option), "none" and false are refused. Every value passes ThemeOptionSpec::check() (type, range, choice),
|
||
* fonts must be known to Divi exactly (FontCheck, with suggestions), and Divi's own sanitizer must keep the value –
|
||
* false or a changed value is an error, since Divi would store false or round silently; the value is stored with the
|
||
* sanitizer's type. A tablet/phone size equal to the size it inherits stays unset. All problems are collected with
|
||
* the path set.<key>. 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<array-key, mixed> $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<string, string> $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<array<string, mixed>> $diff
|
||
* @return list<string>
|
||
*/
|
||
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<string, mixed> 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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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, <ThemeOptionsAdapter der Integration>)` 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 <id> wiederherstellen – <ThemeOptionDiff::summary>`. 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionDiff;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsArea;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
|
||
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsRestorePlanner;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class ThemeOptionsRestorePlannerTest extends TestCase
|
||
{
|
||
public const SNAPSHOT_ID = 'theme_options-20261016T100000Z-aaaaaa';
|
||
|
||
/** @return array<string, int|float|string|false> 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<string, int|float|string|false> 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Restores a snapshot of the theme options (rulings W6, W11): the snapshot replaces the whole whitelist – keys it
|
||
* lacks are deleted and return to Divi's default (ThemeOptionDiff "reset" rows, named in a warning). The rights and
|
||
* Divi features are those of lit/divi-theme-options-update.
|
||
*/
|
||
final class ThemeOptionsRestorePlanner implements RestorePlanner
|
||
{
|
||
private const RESET = 'Theme-Optionen, die im Snapshot nicht gesetzt sind, kehren zum Divi-Standard zurück (%s).';
|
||
|
||
public function __construct(private DesignSystemWriter $writer, private ThemeOptionsAdapter $options)
|
||
{
|
||
}
|
||
|
||
public function area(): string
|
||
{
|
||
return 'theme_options';
|
||
}
|
||
|
||
public function designArea(): DesignArea
|
||
{
|
||
return new ThemeOptionsArea($this->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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 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<string, int|float|string|false> $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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
|
||
### 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<string, mixed>|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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Media;
|
||
|
||
use Brain\Monkey\Actions;
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Media\RemoteFileFetcher;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Error;
|
||
|
||
/**
|
||
* The URL guard and the download shared by the media import and the font upload. MediaImporterTest keeps covering
|
||
* the same guard through fromUrl(); this test pins the parts a second caller relies on: the size limit as
|
||
* parameter, null instead of an error message, the log context.
|
||
*/
|
||
final class RemoteFileFetcherTest extends TestCase
|
||
{
|
||
private string $tmp = '';
|
||
|
||
/** error_log() is a PHP internal and cannot be stubbed; it is redirected to this file. */
|
||
private string $errorLog = '';
|
||
private string|false $previousErrorLog = false;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->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<string> $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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Media;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Log;
|
||
use WP_Error;
|
||
use WpOrg\Requests\Exception as RequestsException;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Downloads a file from a public http(s) URL into a temp file (spec §6.3, §10) – shared by the media import and
|
||
* the font upload. 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() over IPv4 only (core re-validates redirects too), streamed into the temp file and cut off
|
||
* one byte above $maxBytes. A failure reaches the caller only as null (no refused/timeout/status oracle, security
|
||
* audit M3); the reason goes to the log. Not final so abilities can be tested with a fake.
|
||
*/
|
||
class RemoteFileFetcher
|
||
{
|
||
private const TIMEOUT = 30;
|
||
private const REDIRECT_HOOK = 'requests-requests.before_redirect';
|
||
|
||
/** @var Closure(string): list<string> Host name => IPv4 addresses. */
|
||
private Closure $resolve;
|
||
|
||
/** @param (Closure(string): list<string>)|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<string, mixed>|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<string> Host name => IPv4 addresses. */
|
||
- private Closure $resolve;
|
||
+ private RemoteFileFetcher $fetcher;
|
||
|
||
/** @param (Closure(string): list<string>)|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<string, mixed>|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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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', <Name>, …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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\FontSignature;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class FontSignatureTest extends TestCase
|
||
{
|
||
public function testTheFourFormatsAreRecognizedByTheirFirstFourBytes(): void
|
||
{
|
||
self::assertSame('woff2', FontSignature::detect("wOF2\x00\x01\x00\x00\x00\x00\x12\x34"));
|
||
self::assertSame('woff', FontSignature::detect("wOFF\x00\x01\x00\x00"));
|
||
self::assertSame('ttf', FontSignature::detect("\x00\x01\x00\x00\x00\x0c\x00\x80"));
|
||
self::assertSame('ttf', FontSignature::detect('true' . "\x00\x0c"), 'Apple TrueType');
|
||
self::assertSame('otf', FontSignature::detect("OTTO\x00\x0a\x00\x80"));
|
||
}
|
||
|
||
public function testEverythingElseIsNoFont(): void
|
||
{
|
||
self::assertNull(FontSignature::detect(''));
|
||
self::assertNull(FontSignature::detect('wOF'), 'too short');
|
||
self::assertNull(FontSignature::detect('wof2'), 'case matters');
|
||
self::assertNull(FontSignature::detect('ttcf'), 'font collections are not accepted');
|
||
self::assertNull(FontSignature::detect("\x89PNG\r\n\x1a\n"));
|
||
self::assertNull(FontSignature::detect('<?php evil();'));
|
||
self::assertNull(FontSignature::detect("\x00wOF2"), 'the magic must start at byte 0');
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Design/FontNameRulesTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\FontNameRules;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class FontNameRulesTest extends TestCase
|
||
{
|
||
private const RESERVED = ['Open Sans', 'Roboto', 'Arial', 'Times New Roman', 'Hausschrift'];
|
||
|
||
public function testLettersDigitsSpacesUnderscoresAndHyphensAreAllowed(): void
|
||
{
|
||
self::assertNull(FontNameRules::check('Inter Display_2-Bold', self::RESERVED));
|
||
self::assertNull(FontNameRules::check('X', self::RESERVED));
|
||
}
|
||
|
||
public function testOtherCharactersAreRefusedWithTheNameDiviWouldStore(): void
|
||
{
|
||
$problem = (string) FontNameRules::check('Grotesk (Pro)!', self::RESERVED);
|
||
self::assertStringContainsString('nur Buchstaben', $problem);
|
||
self::assertStringContainsString('„Grotesk Pro“', $problem);
|
||
|
||
self::assertStringContainsString('„Mller“', (string) FontNameRules::check('Müller', self::RESERVED));
|
||
self::assertStringContainsString('nur Buchstaben', (string) FontNameRules::check('a.b', self::RESERVED));
|
||
self::assertStringContainsString('nur Buchstaben', (string) FontNameRules::check("Tab\tName", self::RESERVED));
|
||
self::assertStringContainsString('nichts übrig', (string) FontNameRules::check('«»', self::RESERVED));
|
||
}
|
||
|
||
public function testEmptyAndPaddedNamesAreRefused(): void
|
||
{
|
||
self::assertStringContainsString('1–100 Zeichen', (string) FontNameRules::check('', self::RESERVED));
|
||
self::assertStringContainsString('1–100 Zeichen', (string) FontNameRules::check(' ', self::RESERVED));
|
||
self::assertStringContainsString('Anfang oder Ende', (string) FontNameRules::check(' Inter', self::RESERVED));
|
||
self::assertStringContainsString('Anfang oder Ende', (string) FontNameRules::check('Inter ', self::RESERVED));
|
||
}
|
||
|
||
public function testTheLengthLimitIsOneHundredCharactersOrDivisBound(): void
|
||
{
|
||
self::assertNull(FontNameRules::check(str_repeat('a', 100), self::RESERVED));
|
||
self::assertStringContainsString(
|
||
'höchstens 100 Zeichen (101 angegeben)',
|
||
(string) FontNameRules::check(str_repeat('a', 101), self::RESERVED)
|
||
);
|
||
self::assertStringContainsString(
|
||
'höchstens 50 Zeichen',
|
||
(string) FontNameRules::check(str_repeat('a', 51), self::RESERVED, 50)
|
||
);
|
||
}
|
||
|
||
public function testNamesOfGoogleWebSafeAndUploadedFontsAreTakenWhateverTheCase(): void
|
||
{
|
||
self::assertSame('Open Sans', FontNameRules::taken('open sans', self::RESERVED));
|
||
self::assertSame('Arial', FontNameRules::taken('ARIAL', self::RESERVED), 'web-safe fonts too (Divi skips them)');
|
||
self::assertSame('Hausschrift', FontNameRules::taken('Hausschrift', self::RESERVED));
|
||
self::assertNull(FontNameRules::taken('Open Sans Pro', self::RESERVED));
|
||
self::assertNull(FontNameRules::taken('Roboto', []));
|
||
|
||
$problem = (string) FontNameRules::check('times new roman', self::RESERVED);
|
||
self::assertStringContainsString('„Times New Roman“ gibt es schon', $problem);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: RED**
|
||
|
||
Run: `php -d memory_limit=512M vendor/bin/phpunit --filter 'FontSignatureTest|FontNameRulesTest'`
|
||
Expected: `Tests: 7, Assertions: 0, Errors: 7.` (`Class "…\FontSignature" not found` bzw. `"…\FontNameRules" not found`).
|
||
|
||
- [ ] **Step 3: `FontSignature` anlegen**
|
||
|
||
`src/Integrations/Divi/Design/FontSignature.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The font format of a file by its magic bytes (ruling W9): wOF2 = woff2, wOFF = woff, 00 01 00 00 or "true" =
|
||
* ttf, OTTO = otf. Divi's own check (finfo) may be skipped for users with unfiltered_upload, so the font upload
|
||
* checks the content itself. Font collections (ttcf) and eot are no font here. Pure PHP.
|
||
*/
|
||
final class FontSignature
|
||
{
|
||
/** Bytes detect() needs from the start of the file. */
|
||
public const HEADER_BYTES = 4;
|
||
|
||
private const MAGIC = [
|
||
'wOF2' => '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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Name rules of an uploaded font (ruling W9), stricter than FontsUtility::font_add(): Divi silently strips every
|
||
* character but A–Z, a–z, 0–9, whitespace, _ and -; the plugin refuses them and names what Divi would store.
|
||
* Divi compares names with Google's and uploaded fonts case-sensitively and skips the web-safe ones; the plugin
|
||
* refuses any of them whatever the case, so a font never shadows another one in Divi's font lists. Pure PHP.
|
||
*/
|
||
final class FontNameRules
|
||
{
|
||
/** = FontsUtility::FONT_NAME_MAX_LENGTH in Divi 5.15.1 (FontInstaller::nameMaxLength() reads Divi's). */
|
||
public const MAX_LENGTH = 100;
|
||
|
||
private const ALLOWED = '/^[A-Za-z0-9 _-]+$/D';
|
||
|
||
/** What FontsUtility::font_add() removes from a name. */
|
||
private const DIVI_STRIPS = '/[^A-Za-z0-9\s\_-]/';
|
||
|
||
/**
|
||
* @param list<string> $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<string> $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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Design\FontInstaller;
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\ConstantRequirement;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||
use LIT\MCP\Tests\Support\ReleaseMatrices;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use RuntimeException;
|
||
use WP_Error;
|
||
|
||
/**
|
||
* Divi's FontsUtility and HooksRegistration are replaced by Mockery aliases; the tests that need them run in their
|
||
* own process (an alias defines the class for the rest of the process).
|
||
*/
|
||
final class FontInstallerTest extends TestCase
|
||
{
|
||
private const FONTS = 'ET\\Builder\\VisualBuilder\\Fonts\\FontsUtility';
|
||
private const HOOKS = 'ET\\Builder\\VisualBuilder\\Hooks\\HooksRegistration';
|
||
private const UNCHECKED = ['ext' => false, 'type' => false, 'proper_filename' => false];
|
||
|
||
/** @var list<string> */
|
||
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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use ET\Builder\VisualBuilder\Fonts\FontsUtility;
|
||
use ET\Builder\VisualBuilder\Hooks\HooksRegistration;
|
||
use LIT\MCP\Integrations\Divi\Release\Call;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||
use LIT\MCP\Integrations\Divi\Release\Manifest;
|
||
use LIT\MCP\Integrations\Divi\Release\Mirror;
|
||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||
use RuntimeException;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Adapter "Designsystem" (spec §3) for uploaded fonts (ruling W8): installs server-side temp files through
|
||
* FontsUtility::font_add() in-process with the upload action wp_handle_upload_rest – the way Divi's own AI agent
|
||
* does it (AiAgentImageUtility::install_to_custom_fonts(), mirrored as calling convention) – after checking every
|
||
* file with Divi's content check (check_filetype_and_ext_font). font_add() reports a partial success as success;
|
||
* install() therefore returns the formats Divi really stored, the caller compares and removes the font again.
|
||
* Not final so the upload tool can be tested with a fake.
|
||
*/
|
||
class FontInstaller implements DiviAdapter
|
||
{
|
||
/** Formats the plugin allows (ruling W8, no eot), in the order the tool lists them. */
|
||
public const FORMATS = ['woff2', 'woff', 'ttf', 'otf'];
|
||
|
||
private const UNCHECKED = ['ext' => 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<string> 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<string, string> $files Extension => temp file path (deleted or moved by WordPress).
|
||
* @return list<string> 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 (<F> Fingerabdrücke, <S> Signaturen). …` mit `<F>` = Zahl der Mirrors nach Task 2 + 1 und `<S>` = 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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
#### 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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\Divi\Design\DiviFontsUploadAbility;
|
||
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
|
||
use LIT\MCP\Integrations\Divi\Design\FontInstaller;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Media\RemoteFileFetcher;
|
||
use LIT\MCP\Limits;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use Mockery\MockInterface;
|
||
use RuntimeException;
|
||
|
||
final class DiviFontsUploadAbilityTest extends TestCase
|
||
{
|
||
private const WOFF2 = "wOF2\x00\x01\x00\x00woff2-body";
|
||
private const TTF = "\x00\x01\x00\x00\x00\x0cttf-body";
|
||
private const WOFF2_URL = 'https://fonts.example.com/inter.woff2';
|
||
private const TTF_URL = 'https://fonts.example.com/inter.ttf';
|
||
|
||
/** @var list<string> */
|
||
private array $tempFiles = [];
|
||
|
||
/** @var array<string, string|null> URL => body the fake fetcher writes (null = download fails). */
|
||
private array $bodies = [];
|
||
|
||
/** @var list<array{url: string, tmp: string, max: int, context: string}> */
|
||
private array $fetches = [];
|
||
|
||
/** @var array<string, string> 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<string> $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<string, mixed> */
|
||
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] = '<?php evil();';
|
||
|
||
$result = $this->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
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\Rollback;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||
use LIT\MCP\Integrations\Media\AdminIncludes;
|
||
use LIT\MCP\Integrations\Media\RemoteFileFetcher;
|
||
use LIT\MCP\Limits;
|
||
use RuntimeException;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/divi-fonts-upload (spec §8.2, rulings W8–W10): adds a custom font to Divi's uploaded fonts from public URLs,
|
||
* one file per format. No pipeline and no snapshot (A2), but its own dry run: without confirm the files are really
|
||
* downloaded (RemoteFileFetcher, at most 5 MB and the upload limit each), checked by magic bytes against their
|
||
* format key (FontSignature) and by Divi's content check, the temp files deleted again, and the answer is
|
||
* confirm_required with format, size and SHA-256 of every file. confirm: true downloads and checks again and
|
||
* installs (FontInstaller); Divi's partial success is undone with font_remove. Every path deletes the temp files.
|
||
*/
|
||
final class DiviFontsUploadAbility implements Ability, RequiresDiviFeatures
|
||
{
|
||
/** Ruling A9: at most 5 MB per font file, whatever the upload limit allows. */
|
||
public const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||
|
||
public const OBJECT_TYPE = 'divi_font';
|
||
|
||
public const FAMILIES = ['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy'];
|
||
|
||
public const WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||
|
||
private const KEYS = ['name', 'files', 'weights', 'generic_family', 'confirm'];
|
||
|
||
private const DEFAULT_FAMILY = 'sans-serif';
|
||
|
||
private const REASON = 'Divi hat die Schrift nicht vollständig übernommen.';
|
||
|
||
private const FILE_RULE = '1 bis 4 Formate als Objekt {Format: URL}';
|
||
|
||
public function __construct(
|
||
private FontCatalog $catalog,
|
||
private FontInstaller $installer,
|
||
private RemoteFileFetcher $fetcher,
|
||
private ChangeLog $log,
|
||
private Limits $limits
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/divi-fonts-upload';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Eigene Schrift in Divi hochladen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Lädt eine eigene Schrift von öffentlichen URLs in Divis hochgeladene Schriften: files mit je einer '
|
||
. 'Datei pro Format (woff2, woff, ttf, otf; höchstens 5 MB je Datei). name: 1–100 Zeichen aus Buchstaben, '
|
||
. 'Ziffern, Leerzeichen, _ und -, kein Name einer Google-, Web-Safe- oder schon hochgeladenen Schrift. '
|
||
. 'weights: "all" oder Liste aus 100 … 900; generic_family: serif, sans-serif (Standard), monospace, '
|
||
. 'cursive oder fantasy. Wirkt auf die ganze Website: zuerst Probelauf (lädt und prüft die Dateien, nennt '
|
||
. 'Format, Größe und SHA-256), nach Rückfrage beim Nutzer confirm: true. Kein Snapshot; entfernen lässt '
|
||
. 'sich die Schrift im Divi-Builder.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
$url = ['type' => '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<string, mixed> $errors
|
||
* @return array<string, string> 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<string, mixed> $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<string, string> $urls
|
||
* @return array<string, mixed>|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<string, string> $urls
|
||
* @param list<string> $temps Gets every temp file created (the caller deletes them).
|
||
* @return array<string, array{format: string, bytes: int, sha256: string, path: string}>|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<string, string> $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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
|
||
### 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/<name>`), 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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
(`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 <tool> <input JSON>: 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) : '<unset>';"
|
||
}
|
||
|
||
# 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)" = "<unset>" ] || 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=<Admin> 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 <Modellname> <noreply@anthropic.com>"
|
||
```
|
||
|
||
### 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=<Admin> 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.<Format>`); 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<string> 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.
|
||
|