feat: lit/divi-design-restore stellt theme_options wieder her; Hinweis auf Etappe 4d entfällt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 17:58:47 +02:00
co-authored by Claude Sonnet 5.5
parent 2e3b740260
commit bba5a618ed
2 changed files with 104 additions and 14 deletions
@@ -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);
@@ -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<string, int|float|string|false> $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([])
);
}