From bba5a618ed6b02a918f91f6df417c4d0d37f1d1e Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Mon, 19 Oct 2026 17:58:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lit/divi-design-restore=20stellt=20them?= =?UTF-8?q?e=5Foptions=20wieder=20her;=20Hinweis=20auf=20Etappe=204d=20ent?= =?UTF-8?q?f=C3=A4llt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../Divi/Design/DiviDesignRestoreAbility.php | 11 +- .../Design/DiviDesignRestoreAbilityTest.php | 107 ++++++++++++++++-- 2 files changed, 104 insertions(+), 14 deletions(-) diff --git a/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php b/src/Integrations/Divi/Design/DiviDesignRestoreAbility.php index 63d68fb..6b517f4 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 (theme_options until Etappe 4d) are not restorable yet. + * the plan. A snapshot of an area without a planner is refused as unsupported, never treated as a no-op. */ final class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures { @@ -51,8 +51,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. Bereiche: presets, colors ' - . '(Farben, die es im Snapshot nicht gibt, entfallen) und variables (Variablen, die erst nach dem Snapshot ' - . 'entstanden, bleiben archiviert erhalten).'; + . '(Farben, die es im Snapshot nicht gibt, entfallen), variables (Variablen, die erst nach dem Snapshot ' + . 'entstanden, bleiben archiviert erhalten) und theme_options (Theme-Optionen, die im Snapshot nicht ' + . 'gesetzt sind, kehren zum Divi-Standard zurück).'; } public function inputSchema(): array @@ -111,8 +112,8 @@ final class DiviDesignRestoreAbility implements Ability, RequiresDiviFeatures $planner = $this->planners[$area] ?? null; if ($planner === null) { return Errors::unsupported( - 'Snapshots dieses Bereichs lassen sich noch nicht wiederherstellen.', - 'Theme-Optionen folgen mit Etappe 4d.' + 'Snapshots dieses Bereichs lassen sich hier nicht wiederherstellen.', + 'Den Stand des Snapshots im Divi-Builder bzw. -Customizer von Hand herstellen.' ); } $snapshot = $this->snapshots->get($area, $id); diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php index 9678041..c5b6181 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php @@ -14,6 +14,9 @@ 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\ThemeOptionBook; +use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter; +use LIT\MCP\Integrations\Divi\Design\ThemeOptionsRestorePlanner; use LIT\MCP\Integrations\Divi\Design\VariableRestorePlanner; use LIT\MCP\Integrations\Divi\Release\DiviFeature; use LIT\MCP\Tests\Support\FakeChangeLog; @@ -118,7 +121,7 @@ final class DiviDesignRestoreAbilityTest extends TestCase self::assertToolError($ability->execute($input), 'forbidden'); } - public function testUnknownSnapshotAndThemeOptions(): void + public function testUnknownSnapshots(): void { $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->allows('get')->andReturn(null); @@ -127,36 +130,71 @@ final class DiviDesignRestoreAbilityTest extends TestCase 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( + self::assertToolError( $ability->execute(['snapshot_id' => 'theme_options-20261016T100000Z-abcdef']), - 'unsupported' + 'not_found' ); - 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. */ + /** A snapshot area whose planner is not wired stays unsupported (W11) – never a silent no-op. */ + public function testAnAreaWithoutAPlannerIsUnsupported(): void + { + Functions\when('current_user_can')->justReturn(true); + $writer = Mockery::mock(DesignSystemWriter::class); + $writer->allows('canUseVisualBuilder')->andReturn(true); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->shouldNotReceive('get'); + $ability = new DiviDesignRestoreAbility( + new DesignChange($snapshots, FakeChangeLog::forbidden()), + $snapshots, + new PresetRestorePlanner($writer) + ); + + $error = self::assertToolError( + $ability->execute(['snapshot_id' => ThemeOptionsRestorePlannerTest::SNAPSHOT_ID]), + 'unsupported' + ); + self::assertStringNotContainsString('4d', $error['hint']); + } + + /** The ability with the planners of all four areas; the user has every right unless a store says otherwise. */ private static function allAreas( SnapshotStore $snapshots, GlobalColorsAdapter $colors, GlobalVariablesAdapter $variables, - ?FakeChangeLog $log = null + ?FakeChangeLog $log = null, + ?ThemeOptionsAdapter $options = null ): DiviDesignRestoreAbility { Functions\when('current_user_can')->justReturn(true); $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('canUseVisualBuilder')->andReturn(true); $writer->allows('presets')->andReturn(PresetBookTest::stored()); $writer->shouldNotReceive('savePresets'); + $noOptions = []; return new DiviDesignRestoreAbility( new DesignChange($snapshots, $log ?? FakeChangeLog::forbidden()), $snapshots, new PresetRestorePlanner($writer), new ColorRestorePlanner($writer, $colors), - new VariableRestorePlanner($writer, $variables) + new VariableRestorePlanner($writer, $variables), + new ThemeOptionsRestorePlanner($writer, $options ?? self::optionStore($noOptions)) ); } + /** + * A theme options adapter that reads what it last wrote, starting with $stored (ThemeOptionsAreaTest::adapter()). + * + * @param array $stored + */ + private static function optionStore(array &$stored, bool $customize = true): ThemeOptionsAdapter + { + $writes = []; + + return ThemeOptionsAreaTest::adapter($stored, $writes, null, $customize); + } + /** * A colors adapter that reads what it last saved, starting with $stored. * @@ -247,12 +285,54 @@ final class DiviDesignRestoreAbilityTest extends TestCase self::assertSame('archived', $stored['numbers']['gvid-neu0000001']['status']); } + public function testRestoresThemeOptionsAndResetsTheKeysTheSnapshotLacks(): void + { + $id = ThemeOptionsRestorePlannerTest::SNAPSHOT_ID; + $stored = ThemeOptionsRestorePlannerTest::current(); + $snapshots = Mockery::mock(SnapshotStore::class); + $snapshots->allows('get')->with('theme_options', $id) + ->andReturn(self::snapshotIn('theme_options', $id, ThemeOptionsRestorePlannerTest::snapshot())); + $snapshots->expects('save') + ->with( + 'theme_options', + ThemeOptionBook::fromDivi(ThemeOptionsRestorePlannerTest::current())->data(), + 'lit/divi-design-restore' + ) + ->andReturn('theme_options-5'); + $snapshots->expects('attachLog'); + $ability = self::allAreas( + $snapshots, + self::colorStore([]), + self::variableStore([]), + new FakeChangeLog(8), + self::optionStore($stored) + ); + + $dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required'); + self::assertSame(['added', 'changed', 'reset'], array_column($dry['diff'], 'action')); + self::assertStringContainsString('Divi-Standard', $dry['warnings'][1]); + + $result = $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]); + self::assertSame('theme_options-5', $result['snapshot_id']); + self::assertSame( + ThemeOptionBook::fromDivi(ThemeOptionsRestorePlannerTest::snapshot())->data(), + ThemeOptionBook::fromDivi($stored)->data() + ); + } + /** 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)); + $options = []; + $ability = self::allAreas( + $snapshots, + self::colorStore([]), + self::variableStore([], false), + null, + self::optionStore($options, false) + ); self::assertToolError( $ability->permission(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]), @@ -262,6 +342,10 @@ final class DiviDesignRestoreAbilityTest extends TestCase $ability->execute(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]), 'forbidden' ); + self::assertToolError( + $ability->permission(['snapshot_id' => ThemeOptionsRestorePlannerTest::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'); @@ -284,7 +368,12 @@ final class DiviDesignRestoreAbilityTest extends TestCase $ability->diviFeatures(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa']) ); self::assertSame( - [DiviFeature::DesignRead, DiviFeature::PresetWrite, DiviFeature::ColorWrite, DiviFeature::VariableWrite], + [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], + $ability->diviFeatures(['snapshot_id' => ThemeOptionsRestorePlannerTest::SNAPSHOT_ID]) + ); + self::assertSame( + [DiviFeature::DesignRead, DiviFeature::PresetWrite, DiviFeature::ColorWrite, DiviFeature::VariableWrite, + DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $ability->diviFeatures([]) ); }