From 88a939787d7e6f8fc0041a865fe66a78c59fa0e2 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 14:23:10 +0200 Subject: [PATCH] feat: lit/divi-design-restore stellt Farben und Variablen wieder her; nur theme_options folgt mit 4d (V10) Co-Authored-By: Claude Sonnet 5.5 --- .../Divi/Design/DiviDesignRestoreAbility.php | 8 +- src/Integrations/Divi/DiviIntegration.php | 7 +- .../Design/DiviDesignRestoreAbilityTest.php | 182 ++++++++++++++++-- .../Divi/Release/DiviToolFeaturesTest.php | 14 +- 4 files changed, 195 insertions(+), 16 deletions(-) diff --git a/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php b/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php index b717561..63d68fb 100644 --- a/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php @@ -17,7 +17,7 @@ defined('ABSPATH') || exit; * lit/divi-design-restore (spec §5, G6, ruling V10): writes a snapshot back through the change pipeline – dry run with * the diff to the current state, confirm with design_rev, and first a snapshot of the current state, so a restore can * be undone as well. The area comes from the snapshot ID; its RestorePlanner brings the rights, the Divi features and - * the plan. Areas without a planner are not restorable yet. + * the plan. Areas without a planner (theme_options until Etappe 4d) are not restorable yet. */ final class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures { @@ -50,7 +50,9 @@ final class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures { return 'Stellt einen Snapshot des Designsystems wieder her (snapshot_id aus lit/divi-design-snapshots oder ' . 'einer Änderungsantwort). Zuerst Probelauf (Unterschied zum aktuellen Stand, design_rev), nach Rückfrage ' - . 'beim Nutzer confirm: true; vorher wird der aktuelle Stand selbst gesichert.'; + . 'beim Nutzer confirm: true; vorher wird der aktuelle Stand selbst gesichert. Bereiche: presets, colors ' + . '(Farben, die es im Snapshot nicht gibt, entfallen) und variables (Variablen, die erst nach dem Snapshot ' + . 'entstanden, bleiben archiviert erhalten).'; } public function inputSchema(): array @@ -110,7 +112,7 @@ final class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures if ($planner === null) { return Errors::unsupported( 'Snapshots dieses Bereichs lassen sich noch nicht wiederherstellen.', - 'Farben und Variablen folgen mit Etappe 4c, Theme-Optionen mit 4d.' + 'Theme-Optionen folgen mit Etappe 4d.' ); } $snapshot = $this->snapshots->get($area, $id); diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index 6183c34..f37fa9a 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -13,6 +13,7 @@ use LIT\MCP\Integrations\Bootable; use LIT\MCP\Integrations\Divi\Admin\DiviTab; use LIT\MCP\Integrations\Divi\Cli\DiviCheckCommand; use LIT\MCP\Integrations\Divi\BlockTree\Parser; +use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner; use LIT\MCP\Integrations\Divi\Design\DesignSystemReader; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Design\DiviDesignSystemAbility; @@ -22,6 +23,7 @@ use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility; +use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter; use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; use LIT\MCP\Integrations\Divi\Design\DiviPresetsAssignAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility; @@ -31,6 +33,7 @@ use LIT\MCP\Integrations\Divi\Design\PresetAssignment; use LIT\MCP\Integrations\Divi\Design\PresetComposer; use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner; use LIT\MCP\Integrations\Divi\Design\SnapshotStore; +use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner; use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore; use LIT\MCP\Integrations\Divi\Library\DiviRoles; use LIT\MCP\Integrations\Divi\Library\GlobalEdits; @@ -266,7 +269,9 @@ final class DiviIntegration implements Integration, Bootable new DiviDesignRestoreAbility( $this->designChange(), new SnapshotStore(), - new PresetRestorePlanner($this->designWriter, DesignRights::unfilteredHtml()) + new PresetRestorePlanner($this->designWriter, DesignRights::unfilteredHtml()), + new ColorRestorePlanner($this->designWriter, new GlobalColorsAdapter()), + new VariableRestorePlanner($this->designWriter, new GlobalVariablesAdapter()) ), new DiviPresetsAssignAbility( $this->designWriter, diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php index cd6f3aa..9678041 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php @@ -5,14 +5,21 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design; use Brain\Monkey\Functions; +use LIT\MCP\Integrations\Divi\Design\ColorBook; +use LIT\MCP\Integrations\Divi\Design\ColorRestorePlanner; use LIT\MCP\Integrations\Divi\Design\DesignChange; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility; +use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter; +use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; use LIT\MCP\Integrations\Divi\Design\PresetRestorePlanner; use LIT\MCP\Integrations\Divi\Design\SnapshotStore; +use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner; +use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Tests\Support\FakeChangeLog; use LIT\MCP\Tests\Unit\TestCase; use Mockery; +use Mockery\MockInterface; final class DiviDesignRestoreAbilityTest extends TestCase { @@ -111,21 +118,174 @@ final class DiviDesignRestoreAbilityTest extends TestCase self::assertToolError($ability->execute($input), 'forbidden'); } - public function testUnknownSnapshotAndOtherAreas(): void + public function testUnknownSnapshotAndThemeOptions(): void { + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->allows('get')->andReturn(null); + $ability = self::allAreas($snapshots, self::colorStore([]), self::variableStore([])); + + self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-abcdef']), 'not_found'); + self::assertToolError($ability->execute(['snapshot_id' => 'colors-20261016T100000Z-abcdef']), 'not_found'); + self::assertToolError($ability->execute(['snapshot_id' => 'variables-20261016T100000Z-abcdef']), 'not_found'); + $themeOptions = self::assertToolError( + $ability->execute(['snapshot_id' => 'theme_options-20261016T100000Z-abcdef']), + 'unsupported' + ); + self::assertSame('Theme-Optionen folgen mit Etappe 4d.', $themeOptions['hint']); + self::assertToolError($ability->execute(['snapshot_id' => 'kaputt']), 'validation_failed'); + } + + /** The ability with the planners of all three areas; the user has every right unless $manage is false. */ + private static function allAreas( + SnapshotStore $snapshots, + GlobalColorsAdapter $colors, + GlobalVariablesAdapter $variables, + ?FakeChangeLog $log = null + ): DiviDesignRestoreAbility { Functions\when('current_user_can')->justReturn(true); $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('canUseVisualBuilder')->andReturn(true); - $snapshots = Mockery::mock(SnapshotStore::class); - $snapshots->allows('get')->andReturn(null); - $ability = new DiviDesignRestoreAbility( - new DesignChange($snapshots, FakeChangeLog::forbidden()), - $snapshots, - new PresetRestorePlanner($writer) - ); + $writer->allows('presets')->andReturn(PresetBookTest::stored()); + $writer->shouldNotReceive('savePresets'); - self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-abcdef']), 'not_found'); - self::assertToolError($ability->execute(['snapshot_id' => 'colors-20261016T100000Z-abcdef']), 'unsupported'); - self::assertToolError($ability->execute(['snapshot_id' => 'kaputt']), 'validation_failed'); + return new DiviDesignRestoreAbility( + new DesignChange($snapshots, $log ?? FakeChangeLog::forbidden()), + $snapshots, + new PresetRestorePlanner($writer), + new ColorRestorePlanner($writer, $colors), + new VariableRestorePlanner($writer, $variables) + ); + } + + /** + * A colors adapter that reads what it last saved, starting with $stored. + * + * @param array> $stored + */ + private static function colorStore(array $stored): GlobalColorsAdapter&MockInterface + { + $colors = Mockery::mock(GlobalColorsAdapter::class); + $colors->allows('read')->andReturnUsing(static function () use (&$stored): array { + return $stored; + }); + $colors->allows('save')->andReturnUsing(static function (array $entries, bool $replace) use (&$stored): void { + self::assertTrue($replace, 'a restore replaces every color (V4)'); + $stored = $entries; + }); + + return $colors; + } + + /** + * A variables adapter that reads what it last saved, starting with $stored. + * + * @param array>> $stored + */ + private static function variableStore(array $stored, bool $manage = true): GlobalVariablesAdapter&MockInterface + { + $variables = Mockery::mock(GlobalVariablesAdapter::class); + $variables->allows('canManage')->andReturn($manage); + $variables->allows('read')->andReturnUsing(static function () use (&$stored): array { + return $stored; + }); + $variables->allows('save')->andReturnUsing(static function (array $data) use (&$stored): void { + $stored = $data; + }); + + return $variables; + } + + /** @param array $data */ + private static function snapshotIn(string $area, string $id, array $data): array + { + return ['id' => $id, 'data' => $data, 'created' => 'x', 'user_id' => 1, 'ability' => 'lit/divi-' . $area . '-set', + 'log_id' => 2, 'size' => 1]; + } + + public function testRestoresColorsWithDryRunAndASnapshotOfTheCurrentColors(): void + { + $id = ColorRestorePlannerTest::SNAPSHOT_ID; + $colors = self::colorStore(ColorRestorePlannerTest::current()); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->allows('get')->with('colors', $id) + ->andReturn(self::snapshotIn('colors', $id, ColorRestorePlannerTest::snapshot())); + $snapshots->expects('save') + ->with('colors', ColorBook::fromDivi(ColorRestorePlannerTest::current())->data(), 'lit/divi-design-restore') + ->andReturn('colors-5'); + $snapshots->expects('attachLog'); + $ability = self::allAreas($snapshots, $colors, self::variableStore([]), new FakeChangeLog(8)); + + $dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required'); + self::assertSame(['changed', 'removed'], array_column($dry['diff'], 'action')); + self::assertStringContainsString('„Akzent“', $dry['warnings'][0]); + + $result = $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]); + self::assertSame('colors-5', $result['snapshot_id']); + self::assertSame(ColorBook::fromDivi(ColorRestorePlannerTest::snapshot())->data(), $colors->read()); + } + + public function testRestoresVariablesAndKeepsNewerOnesArchived(): void + { + $id = VariableRestorePlannerTest::SNAPSHOT_ID; + $variables = self::variableStore(VariableRestorePlannerTest::current()); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->allows('get')->with('variables', $id) + ->andReturn(self::snapshotIn('variables', $id, VariableRestorePlannerTest::snapshot())); + $snapshots->expects('save') + ->with('variables', Mockery::type('array'), 'lit/divi-design-restore') + ->andReturn('variables-5'); + $snapshots->expects('attachLog'); + $ability = self::allAreas($snapshots, self::colorStore([]), $variables, new FakeChangeLog(8)); + + $dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required'); + self::assertStringContainsString('„Neu“', $dry['warnings'][0]); + + $result = $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]); + self::assertSame('variables-5', $result['snapshot_id']); + $stored = $variables->read(); + self::assertSame('4px', $stored['numbers']['gvid-radius0001']['value']); + self::assertSame('archived', $stored['numbers']['gvid-neu0000001']['status']); + } + + /** Spec §10, V10: the rights are those of the snapshot's area. */ + public function testRightsFollowTheSnapshotArea(): void + { + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->shouldNotReceive('get'); + $ability = self::allAreas($snapshots, self::colorStore([]), self::variableStore([], false)); + + self::assertToolError( + $ability->permission(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]), + 'forbidden' + ); + self::assertToolError( + $ability->execute(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]), + 'forbidden' + ); + self::assertTrue($ability->permission(['snapshot_id' => ColorRestorePlannerTest::SNAPSHOT_ID])); + self::assertTrue($ability->permission(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa'])); + self::assertToolError($ability->permission(['snapshot_id' => 'kaputt']), 'forbidden', 'fail closed'); + } + + public function testFeaturesFollowTheSnapshotArea(): void + { + $ability = self::allAreas(Mockery::mock(SnapshotStore::class), self::colorStore([]), self::variableStore([])); + + self::assertSame( + [DiviFeature::DesignRead, DiviFeature::ColorWrite], + $ability->diviFeatures(['snapshot_id' => ColorRestorePlannerTest::SNAPSHOT_ID]) + ); + self::assertSame( + [DiviFeature::DesignRead, DiviFeature::VariableWrite], + $ability->diviFeatures(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]) + ); + self::assertSame( + [DiviFeature::DesignRead, DiviFeature::PresetWrite], + $ability->diviFeatures(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa']) + ); + self::assertSame( + [DiviFeature::DesignRead, DiviFeature::PresetWrite, DiviFeature::ColorWrite, DiviFeature::VariableWrite], + $ability->diviFeatures([]) + ); } } diff --git a/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php b/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php index b6ebd38..3b746a4 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php @@ -56,7 +56,7 @@ final class DiviToolFeaturesTest extends TestCase 'lit/divi-presets-set' => ['design-read', 'preset-write', 'catalog'], 'lit/divi-presets-delete' => ['design-read', 'preset-write'], 'lit/divi-design-snapshots' => [], - 'lit/divi-design-restore' => ['design-read', 'preset-write'], + 'lit/divi-design-restore' => ['design-read', 'preset-write', 'color-write', 'variable-write'], 'lit/divi-presets-assign' => [ 'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read', 'library-write', 'tb-write', 'roles', @@ -180,6 +180,18 @@ final class DiviToolFeaturesTest extends TestCase 'library-write', 'tb-write', 'roles', 'variable-write', ], ], + 'design-restore of presets' => [ + 'lit/divi-design-restore', ['snapshot_id' => 'presets-20261016T100000Z-aaaaaa'], + ['design-read', 'preset-write'], + ], + 'design-restore of colors' => [ + 'lit/divi-design-restore', ['snapshot_id' => 'colors-20261016T100000Z-aaaaaa'], + ['design-read', 'color-write'], + ], + 'design-restore of variables' => [ + 'lit/divi-design-restore', ['snapshot_id' => 'variables-20261016T100000Z-aaaaaa'], + ['design-read', 'variable-write'], + ], 'library-list' => ['lit/library-list', [], ['library-read', 'roles']], 'library-list of one entry' => [ 'lit/library-list', ['id' => $library], ['library-read', 'roles', 'page-read', 'design-read'],