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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user