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:
Jan-Niclas Loosen
2026-10-19 17:51:50 +02:00
co-authored by Claude Sonnet 5.5
parent 122548db12
commit 879ca752e7
4 changed files with 657 additions and 0 deletions
@@ -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')
);
}
}