Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
352 lines
15 KiB
PHP
352 lines
15 KiB
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']);
|
||
}
|
||
|
||
public function testAFontNameIsTrimmedBeforeTheCatalogCheck(): void
|
||
{
|
||
$result = $this->confirmed($this->ability(self::snapshots()), ['set' => ['heading_font' => ' Roboto']])[1];
|
||
|
||
self::assertSame('theme_options-1', $result['snapshot_id']);
|
||
self::assertSame('Roboto', $this->stored['heading_font']);
|
||
}
|
||
|
||
/** 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);
|
||
}
|
||
|
||
/** A stored false (Divi's default font) is "not set": null is no change, a font is "added". */
|
||
public function testAStoredFalseCountsAsNotSet(): void
|
||
{
|
||
$this->stored = ['heading_font' => false] + $this->stored;
|
||
|
||
self::assertSame([], $this->ability()->execute(['set' => ['heading_font' => null]])['diff']);
|
||
$dry = self::assertToolError(
|
||
$this->ability()->execute(['set' => ['heading_font' => 'Roboto']]),
|
||
'confirm_required'
|
||
);
|
||
self::assertSame([['heading_font', 'added']], array_map(
|
||
static fn (array $row): array => [$row['key'], $row['action']],
|
||
$dry['diff']
|
||
));
|
||
}
|
||
|
||
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]);
|
||
}
|
||
}
|