feat: lit/divi-theme-options-update ändert Theme-Optionen über DesignChange (Prüfung, Divi-Sanitizer, Schriftvorschläge)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
122548db12
commit
879ca752e7
@@ -0,0 +1,246 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<?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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user