feat: Änderungs-Pipeline des Designsystems (Probelauf, design_rev, Snapshot, Rollback) und Bereich presets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
bfd3840eb1
commit
bb4d9b3baf
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* One area of the design system for DesignChange (spec §2, §4): colors, variables, presets or theme options – its
|
||||
* raw inventory as Divi stores it (also the snapshot), its design_rev, how it is written through Divi and how a
|
||||
* read-back is judged. write() throws on failure; it is also how a snapshot is written back ($mayRemove = true).
|
||||
*/
|
||||
interface DesignArea
|
||||
{
|
||||
/** One of SnapshotStore::AREAS. */
|
||||
public function name(): string;
|
||||
|
||||
/** German name for messages, e.g. "Presets". */
|
||||
public function label(): string;
|
||||
|
||||
/** @return array<array-key, mixed> */
|
||||
public function raw(): array;
|
||||
|
||||
/** @param array<array-key, mixed> $raw */
|
||||
public function rev(array $raw): string;
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $raw
|
||||
* @return array<array-key, mixed> The inventory as read back after writing.
|
||||
*/
|
||||
public function write(array $raw, bool $mayRemove): array;
|
||||
|
||||
/**
|
||||
* Whether Divi stored what was written – sanitizing may change values, never which entries exist.
|
||||
*
|
||||
* @param array<array-key, mixed> $expected
|
||||
* @param array<array-key, mixed> $actual
|
||||
*/
|
||||
public function verify(array $expected, array $actual): bool;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
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\DiviLocked;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The change pipeline of every design system write (spec §4, G2, G6): plan on the live inventory → without
|
||||
* confirm a dry run (diff, affected posts, design_rev; nothing saved) → with confirm the design_rev must still match
|
||||
* → no live post may block (P8/P8a) → release lock → snapshot of the area → write through Divi → read back and
|
||||
* verify → change log. A failed write or read-back writes the snapshot back (Rollback); when that fails too the
|
||||
* error is partial_write with the snapshot to restore by hand. The rights are the tool's (spec §10).
|
||||
*/
|
||||
final class DesignChange
|
||||
{
|
||||
public const OBJECT_TYPE = 'divi_design';
|
||||
|
||||
private const LISTED_IDS = 10;
|
||||
|
||||
public function __construct(private SnapshotStore $snapshots, private ChangeLog $log)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>> Input properties every design write tool takes. */
|
||||
public static function inputProperties(): array
|
||||
{
|
||||
return [
|
||||
'confirm' => [
|
||||
'type' => 'boolean',
|
||||
'description' => 'Erst nach dem Probelauf und der Rückfrage beim Nutzer.',
|
||||
],
|
||||
'design_rev' => [
|
||||
'type' => 'string',
|
||||
'description' => 'design_rev aus dem Probelauf (Pflicht mit confirm).',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public static function outputSchema(): array
|
||||
{
|
||||
return ToolResult::schema([
|
||||
'diff' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'design_rev' => ['type' => 'string'],
|
||||
], ['diff', 'design_rev']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input The tool input (confirm, design_rev).
|
||||
* @param callable(array<array-key, mixed>): (DesignPlan|WP_Error) $plan
|
||||
* @return array<string, mixed>|WP_Error
|
||||
* @throws WriteFailed
|
||||
* @throws DiviLocked
|
||||
*/
|
||||
public function run(string $ability, DesignArea $area, array $input, callable $plan): array|WP_Error
|
||||
{
|
||||
$current = $area->raw();
|
||||
$rev = $area->rev($current);
|
||||
$planned = $plan($current);
|
||||
if ($planned instanceof WP_Error) {
|
||||
return $planned;
|
||||
}
|
||||
|
||||
if ($planned->isEmpty()) {
|
||||
return ToolResult::ok('Keine Änderung – ' . $planned->summary)
|
||||
->with('diff', [])
|
||||
->with('design_rev', $rev)
|
||||
->warnings($planned->warnings)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
if (($input['confirm'] ?? false) !== true) {
|
||||
return Errors::confirmRequired(
|
||||
$planned->summary . ' Nichts wurde gespeichert (Probelauf).',
|
||||
['diff' => $planned->diff, 'design_rev' => $rev, 'warnings' => $planned->warnings]
|
||||
+ ($planned->usages?->listing() ?? ['affected' => [], 'affected_count' => 0])
|
||||
+ ($planned->usages !== null && $planned->usages->blocked !== []
|
||||
? ['blocked_ids' => array_slice($planned->usages->blocked, 0, self::LISTED_IDS)] : [])
|
||||
);
|
||||
}
|
||||
|
||||
if (($input['design_rev'] ?? null) !== $rev) {
|
||||
return Errors::revMismatch(
|
||||
sprintf('Die %s wurden seit dem Probelauf geändert; nichts wurde gespeichert.', $area->label()),
|
||||
'Erneut ohne confirm aufrufen (Probelauf), dem Nutzer die Änderung zeigen und mit der neuen '
|
||||
. 'design_rev bestätigen.'
|
||||
);
|
||||
}
|
||||
|
||||
$blocked = $this->blocked($planned);
|
||||
if ($blocked !== null) {
|
||||
return $blocked;
|
||||
}
|
||||
|
||||
ReleaseGate::assertWritable();
|
||||
try {
|
||||
$snapshotId = $this->snapshots->save($area->name(), $current, $ability);
|
||||
} catch (SnapshotTooLarge $e) {
|
||||
return Errors::make(
|
||||
'snapshot_too_large',
|
||||
sprintf(
|
||||
'Die %s sind zu groß für eine Sicherung (%d Bytes, höchstens %d); nichts wurde gespeichert.',
|
||||
$area->label(),
|
||||
$e->size,
|
||||
SnapshotStore::MAX_BYTES
|
||||
),
|
||||
'Ohne Sicherung schreibt LIT MCP nicht. Die Änderung im Divi-Builder vornehmen oder unbenutzte '
|
||||
. 'Einträge dort entfernen.',
|
||||
413
|
||||
);
|
||||
}
|
||||
|
||||
$saved = $this->write($ability, $area, $planned, $current, $snapshotId);
|
||||
$logId = $this->log->record(
|
||||
$ability,
|
||||
self::OBJECT_TYPE,
|
||||
$area->name(),
|
||||
$planned->summary,
|
||||
snapshotId: $snapshotId
|
||||
);
|
||||
$this->snapshots->attachLog($area->name(), $snapshotId, $logId);
|
||||
|
||||
return ToolResult::ok($planned->summary)
|
||||
->snapshotId($snapshotId)
|
||||
->logId($logId)
|
||||
->warnings($planned->warnings)
|
||||
->with('diff', $planned->diff)
|
||||
->with('design_rev', $area->rev($saved))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** Spec §4 / rulings P8, P8a: a confirmed change is refused while the caller may not edit a live post it changes. */
|
||||
private function blocked(DesignPlan $plan): ?WP_Error
|
||||
{
|
||||
$usages = $plan->usages;
|
||||
if ($usages === null || ! $usages->blocks()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Errors::make(
|
||||
'forbidden',
|
||||
$usages->tooMany
|
||||
? 'Die Änderung betrifft zu viele veröffentlichte Seiten, um sie einzeln zu prüfen; nichts wurde '
|
||||
. 'gespeichert.'
|
||||
: 'Die Änderung wirkt auf veröffentlichte Seiten, die dieses Konto nicht bearbeiten darf; nichts wurde '
|
||||
. 'gespeichert.',
|
||||
'Einen Administrator die Änderung vornehmen lassen oder sie im Divi-Builder ausführen.',
|
||||
403,
|
||||
['blocked_ids' => array_slice($usages->blocked, 0, self::LISTED_IDS)]
|
||||
+ ($usages->blockedHidden > 0 ? ['blocked_hidden_count' => $usages->blockedHidden] : [])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $current
|
||||
* @return array<array-key, mixed>
|
||||
* @throws WriteFailed
|
||||
*/
|
||||
private function write(
|
||||
string $ability,
|
||||
DesignArea $area,
|
||||
DesignPlan $plan,
|
||||
array $current,
|
||||
string $snapshotId
|
||||
): array {
|
||||
try {
|
||||
$saved = $area->write($plan->raw, $plan->mayRemove);
|
||||
if (! $area->verify($plan->raw, $saved)) {
|
||||
throw new RuntimeException(sprintf('%s read back differs from what was written', $area->name()));
|
||||
}
|
||||
|
||||
return $saved;
|
||||
} catch (Throwable $e) {
|
||||
if (DiviLocked::in($e) !== null) {
|
||||
throw $e;
|
||||
}
|
||||
$this->rollBack($ability, $area, $current, $snapshotId, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $current
|
||||
* @throws WriteFailed Always.
|
||||
*/
|
||||
private function rollBack(
|
||||
string $ability,
|
||||
DesignArea $area,
|
||||
array $current,
|
||||
string $snapshotId,
|
||||
Throwable $cause
|
||||
): never {
|
||||
$reason = sprintf('Die %s konnten nicht gespeichert werden.', $area->label());
|
||||
$failures = Rollback::attempt(static fn () => $area->write($current, true));
|
||||
if ($failures === []) {
|
||||
throw WriteFailed::rolledBack(
|
||||
$reason,
|
||||
$cause,
|
||||
sprintf('design %s write failed: %s', $area->name(), $cause->getMessage()),
|
||||
details: ['snapshot_id' => $snapshotId]
|
||||
);
|
||||
}
|
||||
|
||||
$this->log->recordPartial(
|
||||
$ability,
|
||||
self::OBJECT_TYPE,
|
||||
$area->name(),
|
||||
sprintf('%s nicht zurückgenommen (%s); Snapshot %s', $area->label(), $failures[0], $snapshotId)
|
||||
);
|
||||
throw WriteFailed::partial(
|
||||
$reason,
|
||||
$cause,
|
||||
sprintf('design %s rollback failed: %s', $area->name(), $failures[0]),
|
||||
state: sprintf(
|
||||
'Die %s sind möglicherweise teilweise geändert (Stand vorher: Snapshot %s).',
|
||||
$area->label(),
|
||||
$snapshotId
|
||||
),
|
||||
hint: sprintf(
|
||||
'Den Aufruf nicht wiederholen. Mit lit/divi-design-restore und snapshot_id %s (Probelauf, dann '
|
||||
. 'confirm: true) den vorherigen Stand wiederherstellen und dem Nutzer Bescheid geben.',
|
||||
$snapshotId
|
||||
),
|
||||
details: ['snapshot_id' => $snapshotId]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* What a design change would do (spec §4 step 3–4): the new raw inventory, the diff, a one-sentence German summary,
|
||||
* the posts it affects (null = none to report), warnings and whether entries are removed (delete/restore).
|
||||
*/
|
||||
final class DesignPlan
|
||||
{
|
||||
/**
|
||||
* @param array<array-key, mixed> $raw
|
||||
* @param list<array<string, mixed>> $diff
|
||||
* @param list<string> $warnings
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $raw,
|
||||
public readonly array $diff,
|
||||
public readonly string $summary,
|
||||
public readonly ?UsageReport $usages = null,
|
||||
public readonly array $warnings = [],
|
||||
public readonly bool $mayRemove = false
|
||||
) {
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this->diff === [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Design;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The area "presets" (spec §6): the inventory in Divi's stored format (PresetBook), written as REST format through
|
||||
* DesignSystemWriter::savePresets(). Divi's prepare_data() may sanitize values; verify() only insists on the same
|
||||
* presets and defaults (PresetDiff without "changed").
|
||||
*/
|
||||
final class PresetArea implements DesignArea
|
||||
{
|
||||
public function __construct(private DesignSystemWriter $writer)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'presets';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Presets';
|
||||
}
|
||||
|
||||
public function raw(): array
|
||||
{
|
||||
return PresetBook::fromDivi($this->writer->presets())->data();
|
||||
}
|
||||
|
||||
public function rev(array $raw): string
|
||||
{
|
||||
return PresetBook::fromDivi($raw)->rev();
|
||||
}
|
||||
|
||||
public function write(array $raw, bool $mayRemove): array
|
||||
{
|
||||
return PresetBook::fromDivi($this->writer->savePresets(PresetBook::fromDivi($raw)->toRest(), $mayRemove))
|
||||
->data();
|
||||
}
|
||||
|
||||
public function verify(array $expected, array $actual): bool
|
||||
{
|
||||
$structural = array_filter(
|
||||
PresetDiff::between(PresetBook::fromDivi($expected), PresetBook::fromDivi($actual)),
|
||||
static fn (array $row): bool => $row['action'] !== 'changed'
|
||||
);
|
||||
|
||||
return $structural === [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignArea;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignChange;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
|
||||
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
|
||||
use LIT\MCP\Integrations\Divi\Design\SnapshotTooLarge;
|
||||
use LIT\MCP\Integrations\Divi\Library\UsageReport;
|
||||
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||||
use LIT\MCP\Abilities\WriteFailed;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
use RuntimeException;
|
||||
|
||||
final class DesignChangeTest extends TestCase
|
||||
{
|
||||
/** A DesignArea over an array; $failWrite/$failRestore make write()/the rollback throw. */
|
||||
private static function area(array &$stored, int $failWrites = 0, bool $verify = true): DesignArea
|
||||
{
|
||||
return new class ($stored, $failWrites, $verify) implements DesignArea {
|
||||
public array $writes = [];
|
||||
|
||||
public function __construct(private array &$stored, private int $failWrites, private bool $verify)
|
||||
{
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'presets';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Presets';
|
||||
}
|
||||
|
||||
public function raw(): array
|
||||
{
|
||||
return $this->stored;
|
||||
}
|
||||
|
||||
public function rev(array $raw): string
|
||||
{
|
||||
return substr(md5((string) json_encode($raw)), 0, 12);
|
||||
}
|
||||
|
||||
public function write(array $raw, bool $mayRemove): array
|
||||
{
|
||||
$this->writes[] = [$raw, $mayRemove];
|
||||
if ($this->failWrites > 0) {
|
||||
--$this->failWrites;
|
||||
throw new RuntimeException('divi refused');
|
||||
}
|
||||
$this->stored = $raw;
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
public function verify(array $expected, array $actual): bool
|
||||
{
|
||||
return $this->verify;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static function plan(array $raw, ?UsageReport $usages = null): callable
|
||||
{
|
||||
return static fn (array $current): DesignPlan => new DesignPlan(
|
||||
$raw,
|
||||
[['kind' => 'module', 'scope' => 'divi/button', 'id' => 'a', 'name' => 'CTA', 'action' => 'added']],
|
||||
'Preset „CTA“ wird angelegt.',
|
||||
$usages
|
||||
);
|
||||
}
|
||||
|
||||
private static function usages(array $blocked = []): UsageReport
|
||||
{
|
||||
return new UsageReport(1, [['post_id' => 3, 'title' => 'Start', 'status' => 'publish', 'type' => 'page']],
|
||||
0, 1, $blocked, 0, false);
|
||||
}
|
||||
|
||||
public function testDryRunListsDiffAffectedAndRevAndSavesNothing(): void
|
||||
{
|
||||
$stored = ['v' => 1];
|
||||
$area = self::area($stored);
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->shouldNotReceive('save');
|
||||
|
||||
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))
|
||||
->run('lit/divi-presets-set', $area, [], self::plan(['v' => 2], self::usages()));
|
||||
|
||||
$details = self::assertToolError($result, 'confirm_required');
|
||||
self::assertStringContainsString('Preset „CTA“ wird angelegt. Nichts wurde gespeichert (Probelauf).', $result->get_error_message());
|
||||
self::assertSame('added', $details['diff'][0]['action']);
|
||||
self::assertSame($area->rev(['v' => 1]), $details['design_rev']);
|
||||
self::assertSame(3, $details['affected'][0]['post_id']);
|
||||
self::assertSame(1, $details['affected_count']);
|
||||
self::assertSame([], $area->writes);
|
||||
}
|
||||
|
||||
public function testNothingToDoIsNoDryRun(): void
|
||||
{
|
||||
$stored = ['v' => 1];
|
||||
$empty = static fn (array $current): DesignPlan => new DesignPlan($current, [], 'Nichts.');
|
||||
|
||||
$result = (new DesignChange(Mockery::mock(SnapshotStore::class), FakeChangeLog::forbidden()))
|
||||
->run('lit/divi-presets-set', self::area($stored), [], $empty);
|
||||
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertSame([], $result['diff']);
|
||||
self::assertStringStartsWith('Keine Änderung', $result['summary']);
|
||||
}
|
||||
|
||||
public function testStaleDesignRevWritesNothing(): void
|
||||
{
|
||||
$stored = ['v' => 1];
|
||||
$area = self::area($stored);
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->shouldNotReceive('save');
|
||||
|
||||
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))
|
||||
->run('lit/divi-presets-set', $area, ['confirm' => true, 'design_rev' => 'veraltet'], self::plan(['v' => 2]));
|
||||
|
||||
self::assertToolError($result, 'rev_mismatch');
|
||||
self::assertStringContainsString('Presets wurden seit dem Probelauf geändert', $result->get_error_message());
|
||||
self::assertSame([], $area->writes);
|
||||
}
|
||||
|
||||
public function testBlockedUsagesRefuseTheConfirm(): void
|
||||
{
|
||||
$stored = ['v' => 1];
|
||||
$area = self::area($stored);
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->shouldNotReceive('save');
|
||||
|
||||
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
||||
'lit/divi-presets-set',
|
||||
$area,
|
||||
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
||||
self::plan(['v' => 2], self::usages([3]))
|
||||
);
|
||||
|
||||
$details = self::assertToolError($result, 'forbidden');
|
||||
self::assertSame([3], $details['blocked_ids']);
|
||||
}
|
||||
|
||||
public function testConfirmTakesASnapshotWritesAndLogs(): void
|
||||
{
|
||||
$stored = ['v' => 1];
|
||||
$area = self::area($stored);
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->expects('save')->with('presets', ['v' => 1], 'lit/divi-presets-set')->andReturn('presets-1');
|
||||
$snapshots->expects('attachLog')->with('presets', 'presets-1', 9);
|
||||
$log = new FakeChangeLog(9);
|
||||
|
||||
$result = (new DesignChange($snapshots, $log))->run(
|
||||
'lit/divi-presets-set',
|
||||
$area,
|
||||
['confirm' => true, 'design_rev' => $area->rev(['v' => 1])],
|
||||
self::plan(['v' => 2])
|
||||
);
|
||||
|
||||
self::assertTrue($result['ok']);
|
||||
self::assertSame('presets-1', $result['snapshot_id']);
|
||||
self::assertSame(9, $result['log_id']);
|
||||
self::assertSame($area->rev(['v' => 2]), $result['design_rev']);
|
||||
self::assertSame(['v' => 2], $stored);
|
||||
self::assertSame('presets', $log->records[0]['object_id']);
|
||||
self::assertSame('divi_design', $log->records[0]['object_type']);
|
||||
self::assertSame('presets-1', $log->records[0]['snapshot_id']);
|
||||
}
|
||||
|
||||
public function testLockedDiviWritesNoSnapshot(): void
|
||||
{
|
||||
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.16.0'));
|
||||
$stored = ['v' => 1];
|
||||
$area = self::area($stored);
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->shouldNotReceive('save');
|
||||
|
||||
$this->expectException(DiviLocked::class);
|
||||
|
||||
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
||||
'lit/divi-presets-set',
|
||||
$area,
|
||||
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
||||
self::plan(['v' => 2])
|
||||
);
|
||||
}
|
||||
|
||||
public function testTooLargeSnapshotRefusesTheChange(): void
|
||||
{
|
||||
$stored = ['v' => 1];
|
||||
$area = self::area($stored);
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->expects('save')->andThrow(new SnapshotTooLarge(3000000));
|
||||
|
||||
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
||||
'lit/divi-presets-set',
|
||||
$area,
|
||||
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
||||
self::plan(['v' => 2])
|
||||
);
|
||||
|
||||
self::assertToolError($result, 'snapshot_too_large');
|
||||
self::assertSame([], $area->writes);
|
||||
}
|
||||
|
||||
public function testFailedWriteRestoresTheSnapshot(): void
|
||||
{
|
||||
$stored = ['v' => 1];
|
||||
$area = self::area($stored, 1);
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->expects('save')->andReturn('presets-1');
|
||||
|
||||
try {
|
||||
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
||||
'lit/divi-presets-set',
|
||||
$area,
|
||||
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
||||
self::plan(['v' => 2])
|
||||
);
|
||||
self::fail('expected WriteFailed');
|
||||
} catch (WriteFailed $e) {
|
||||
self::assertTrue($e->rolledBack);
|
||||
self::assertSame('presets-1', $e->details['snapshot_id']);
|
||||
}
|
||||
|
||||
self::assertSame([[['v' => 2], false], [['v' => 1], true]], $area->writes);
|
||||
self::assertSame(['v' => 1], $stored);
|
||||
}
|
||||
|
||||
public function testReadBackMismatchRestoresTheSnapshot(): void
|
||||
{
|
||||
$stored = ['v' => 1];
|
||||
$area = self::area($stored, 0, false);
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->expects('save')->andReturn('presets-1');
|
||||
|
||||
$this->expectException(WriteFailed::class);
|
||||
|
||||
(new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
|
||||
'lit/divi-presets-set',
|
||||
$area,
|
||||
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
||||
self::plan(['v' => 2])
|
||||
);
|
||||
}
|
||||
|
||||
public function testFailedRollbackIsPartial(): void
|
||||
{
|
||||
$stored = ['v' => 1];
|
||||
$area = self::area($stored, 2);
|
||||
$snapshots = Mockery::mock(SnapshotStore::class);
|
||||
$snapshots->expects('save')->andReturn('presets-1');
|
||||
$log = FakeChangeLog::partialsOnly();
|
||||
|
||||
try {
|
||||
(new DesignChange($snapshots, $log))->run(
|
||||
'lit/divi-presets-set',
|
||||
$area,
|
||||
['confirm' => true, 'design_rev' => $area->rev($stored)],
|
||||
self::plan(['v' => 2])
|
||||
);
|
||||
self::fail('expected WriteFailed');
|
||||
} catch (WriteFailed $e) {
|
||||
self::assertFalse($e->rolledBack);
|
||||
self::assertStringContainsString('presets-1', (string) $e->hint);
|
||||
}
|
||||
|
||||
self::assertCount(1, $log->partials);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
|
||||
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetArea;
|
||||
use LIT\MCP\Integrations\Divi\Design\PresetBook;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class PresetAreaTest extends TestCase
|
||||
{
|
||||
public function testWritesTheRestFormatAndReadsBack(): void
|
||||
{
|
||||
$writer = Mockery::mock(DesignSystemWriter::class);
|
||||
$stored = PresetBookTest::stored();
|
||||
$writer->allows('presets')->andReturn($stored);
|
||||
$writer->expects('savePresets')
|
||||
->with(PresetBook::fromDivi($stored)->toRest(), true)
|
||||
->andReturn($stored);
|
||||
$area = new PresetArea($writer);
|
||||
|
||||
self::assertSame(['presets', 'Presets'], [$area->name(), $area->label()]);
|
||||
self::assertSame(PresetBook::fromDivi($stored)->data(), $area->raw());
|
||||
self::assertSame(PresetBook::fromDivi($stored)->rev(), $area->rev($stored));
|
||||
self::assertSame(PresetBook::fromDivi($stored)->data(), $area->write($stored, true));
|
||||
}
|
||||
|
||||
public function testVerifyIgnoresSanitizedValuesButNotMissingPresets(): void
|
||||
{
|
||||
$area = new PresetArea(Mockery::mock(DesignSystemWriter::class));
|
||||
$expected = PresetBookTest::stored();
|
||||
$sanitized = $expected;
|
||||
$sanitized['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = ['button' => ['x' => 'bereinigt']];
|
||||
$missing = $expected;
|
||||
unset($missing['module']['divi/button']['items']['bbbbbbbbbb']);
|
||||
$otherDefault = $expected;
|
||||
$otherDefault['module']['divi/text']['default'] = '';
|
||||
|
||||
self::assertTrue($area->verify($expected, $sanitized));
|
||||
self::assertFalse($area->verify($expected, $missing));
|
||||
self::assertFalse($area->verify($expected, $otherDefault));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user