feat: lit/divi-colors-set legt globale Farben an, ändert und deaktiviert sie (Probelauf, design_rev, Snapshot)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 14:07:23 +02:00
co-authored by Claude Sonnet 5.5
parent c1f77d2017
commit 337b87b572
2 changed files with 645 additions and 0 deletions
@@ -0,0 +1,303 @@
<?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\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Design\ColorBook;
use LIT\MCP\Integrations\Divi\Design\ColorDiff;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DiviColorsSetAbility;
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport;
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 DiviColorsSetAbilityTest extends TestCase
{
/** @var array<string, array<string, mixed>> What the adapter stores (ColorAreaTest::adapter()). */
private array $stored = [];
/** @var list<array{0: array<string, mixed>, 1: bool}> */
private array $saves = [];
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 = ColorAreaTest::stored();
$this->saves = [];
}
/** @param array<string, mixed> $presets DesignSystemWriter::presets() ([] = PresetBookTest::stored()). */
private function ability(
?GlobalUsages $usages = null,
?SnapshotStore $snapshots = null,
array $presets = [],
?GlobalColorsAdapter $colors = null,
bool $visualBuilder = true
): DiviColorsSetAbility {
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('canUseVisualBuilder')->andReturn($visualBuilder);
$writer->allows('presets')->andReturn($presets === [] ? PresetBookTest::stored() : $presets);
$counter = 0;
return new DiviColorsSetAbility(
$writer,
$colors ?? ColorAreaTest::adapter($this->stored, $this->saves),
new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3)),
$usages ?? self::usages(self::unused()),
static function (int $length) use (&$counter): string {
return str_pad((string) ++$counter, $length, '0', STR_PAD_LEFT);
}
);
}
private static function usages(UsageReport $report): GlobalUsages
{
$usages = Mockery::mock(GlobalUsages::class);
$usages->allows('reportFor')->andReturn($report);
return $usages;
}
private static function unused(): UsageReport
{
return new UsageReport(0, [], 0, 0, [], 0, false);
}
private static function used(): UsageReport
{
return new UsageReport(1, [['post_id' => 7, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']],
0, 1, [], 0, false);
}
/** A SnapshotStore that expects one snapshot of the stored colors and the log ID 3 attached to it. */
private static function snapshots(): SnapshotStore
{
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')
->with('colors', ColorBook::fromDivi(ColorAreaTest::stored())->data(), 'lit/divi-colors-set')
->andReturn('colors-1');
$snapshots->expects('attachLog')->with('colors', 'colors-1', 3);
return $snapshots;
}
public function testSchemaAndMeta(): void
{
$ability = $this->ability();
$properties = $ability->inputSchema()['properties'];
self::assertSame('lit/divi-colors-set', $ability->name());
self::assertFalse($ability->isDirect());
self::assertSame(Annotations::destructive(false), $ability->annotations());
self::assertSame([DiviFeature::DesignRead, DiviFeature::ColorWrite], $ability->diviFeatures([]));
self::assertSame(DiviColorsSetAbility::MAX_ITEMS, $properties['items']['maxItems']);
self::assertSame(50, DiviColorsSetAbility::MAX_ITEMS);
self::assertArrayHasKey('design_rev', $properties);
self::assertArrayNotHasKey('delete', $properties['items']['items']['properties'], 'colors are deactivated');
self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2));
self::assertStringContainsString('confirm: true mit design_rev', $ability->description());
self::assertStringContainsString('lit/divi-design-restore', $ability->description());
}
public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void
{
$input = ['items' => [['label' => 'Weiß', 'color' => '#ffffff']]];
self::assertToolError($this->ability(visualBuilder: false)->execute($input), 'forbidden');
self::assertToolError($this->ability(visualBuilder: 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->saves);
}
public function testItemsMustBeAListOfOneToFifty(): void
{
$tooMany = array_fill(0, 51, ['label' => 'X', 'color' => '#fff']);
foreach ([[], ['items' => []], ['items' => ['a' => []]], ['items' => $tooMany]] as $input) {
self::assertToolError($this->ability()->execute($input), 'validation_failed');
}
}
public function testDryRunThenConfirmCreatesAColor(): void
{
$usages = Mockery::mock(GlobalUsages::class);
$usages->expects('reportFor')->never(); // ruling V8: a new color has no usages
$ability = $this->ability($usages, self::snapshots());
$input = ['items' => [['label' => 'Weiß', 'color' => '#ffffff', 'folder' => 'Basis']]];
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
self::assertSame(['added'], array_column($dry['diff'], 'action'));
self::assertSame('Weiß', $dry['diff'][0]['name']);
self::assertSame(ColorBook::fromDivi(ColorAreaTest::stored())->rev(), $dry['design_rev']);
self::assertSame([], $dry['affected']);
self::assertSame(0, $dry['affected_count']);
self::assertSame([], $dry['warnings']);
self::assertStringContainsString(ColorDiff::summary($dry['diff']), $dry['message']);
self::assertSame([], $this->saves, 'the dry run saves nothing');
$result = $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
self::assertSame('colors-1', $result['snapshot_id']);
$created = array_diff_key($this->stored, ColorAreaTest::stored());
self::assertCount(1, $created);
self::assertMatchesRegularExpression('/^gcid-[0-9a-z]{10}$/D', (string) array_key_first($created));
$entry = reset($created);
self::assertSame(
['#ffffff', 'Basis', 'Weiß', 'active', []],
[$entry['color'], $entry['folder'], $entry['label'], $entry['status'], $entry['usedInPosts']]
);
self::assertMatchesRegularExpression('/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/D', $entry['lastUpdated']);
self::assertFalse($this->saves[0][1], 'merged');
self::assertSame(ColorBook::fromDivi($this->stored)->rev(), $result['design_rev']);
}
public function testConfirmNeedsTheDesignRevOfTheDryRun(): void
{
$input = ['items' => [['id' => 'Holz dunkel', 'color' => '#000000']], 'confirm' => true];
self::assertToolError($this->ability()->execute($input), 'validation_failed');
self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch');
self::assertSame([], $this->saves);
}
public function testChangingAColorWritesOnlyThatColorAndListsItsUsages(): void
{
$usages = Mockery::mock(GlobalUsages::class);
$usages->expects('reportFor')
->twice() // dry run and confirm
->with(Mockery::on(static fn (ReferenceUsageQuery $query): bool
=> $query->matches('<!-- wp:divi/text {"x":"gcid-holz000001"} /-->')
&& ! $query->matches('<!-- wp:divi/text {"x":"gcid-sand000002"} /-->')))
->andReturn(self::used());
$ability = $this->ability($usages, self::snapshots());
$input = ['items' => [['id' => 'holz DUNKEL', 'color' => '#000000', 'label' => 'Holz schwarz']]];
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
self::assertSame(['changed'], array_column($dry['diff'], 'action'));
self::assertSame('gcid-holz000001', $dry['diff'][0]['id']);
self::assertSame(7, $dry['affected'][0]['post_id']);
self::assertSame(1, $dry['affected_count']);
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
self::assertSame(['gcid-holz000001'], array_keys($this->saves[0][0]));
self::assertSame(
['#000000', 'Holz schwarz'],
[$this->stored['gcid-holz000001']['color'], $this->stored['gcid-holz000001']['label']]
);
}
public function testDeactivatingIsHowAColorIsRemoved(): void
{
$dry = self::assertToolError(
$this->ability()->execute(['items' => [['id' => 'gcid-holz000001', 'status' => 'inactive']]]),
'confirm_required'
);
self::assertSame(['deactivated'], array_column($dry['diff'], 'action'));
}
public function testAnItemThatChangesNothingIsNoChange(): void
{
$result = $this->ability()->execute(['items' => [['id' => 'Holz dunkel', 'color' => '#5b3a29']]]);
self::assertSame([], $result['diff']);
self::assertStringStartsWith('Keine Änderung', $result['summary']);
self::assertSame([], $this->saves);
}
/** Ruling V3: a main color is found by its option name and only its value is written. */
public function testMainColorsChangeOnlyTheirValue(): void
{
$ability = $this->ability(null, self::snapshots());
$input = ['items' => [['id' => 'accent_color', 'color' => '#123456']]];
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
self::assertSame('gcid-primary-color', $dry['diff'][0]['id']);
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
self::assertSame(['gcid-primary-color'], array_keys($this->saves[0][0]));
self::assertSame('#123456', $this->stored['gcid-primary-color']['color']);
}
public function testMainColorsCannotBeRenamedMovedOrDeactivated(): void
{
$error = $this->ability()->execute(['items' => [
['id' => 'primary', 'label' => 'Haupt', 'folder' => 'Basis', 'status' => 'inactive'],
]]);
self::assertToolError($error, 'validation_failed');
self::assertSame(
['items[0].label', 'items[0].folder', 'items[0].status'],
array_keys(self::errorFields($error))
);
}
public function testAllProblemsOfAllItemsAreCollected(): void
{
$error = $this->ability()->execute(['items' => [
['id' => 'Fehlt', 'color' => '#ffffff'],
['label' => '', 'color' => 'kein Wert!'],
['color' => '#ffffff'],
['label' => 'Holz dunkel', 'color' => '#ffffff'],
['id' => 'gcid-holz000001', 'folder' => ['x'], 'status' => 'gelöscht'],
'kein Objekt',
]]);
self::assertToolError($error, 'validation_failed');
self::assertSame(
['items[0].id', 'items[1].label', 'items[1].color', 'items[2].label', 'items[3].label', 'items[4].folder',
'items[4].status', 'items[5]'],
array_keys(self::errorFields($error))
);
self::assertSame([], $this->saves);
}
public function testAFailedWriteIsRolledBack(): void
{
$calls = [];
$colors = Mockery::mock(GlobalColorsAdapter::class);
$colors->allows('read')->andReturn(ColorAreaTest::stored());
$colors->allows('save')->andReturnUsing(static function (array $entries, bool $replace) use (&$calls): void {
$calls[] = $replace;
if (count($calls) === 1) {
throw new RuntimeException('Divi hat nichts gespeichert');
}
});
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('colors-1');
$ability = $this->ability(null, $snapshots, [], $colors);
$input = ['items' => [['id' => 'Holz dunkel', 'color' => '#000000']]];
$dry = self::assertToolError($ability->execute($input), 'confirm_required');
try {
$ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']]);
self::fail('expected WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
self::assertSame('colors-1', $e->details['snapshot_id']);
}
self::assertSame([false, true], $calls, 'the merge failed, the snapshot replaced all colors');
}
}