fix: DesignArea mit restore() fürs Zurückschreiben und differences() für bereinigte Werte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 18:04:34 +02:00
co-authored by Claude Opus 5.5
parent 60c31f9697
commit fc8ffeba9e
5 changed files with 130 additions and 12 deletions
+21 -2
View File
@@ -9,7 +9,8 @@ defined('ABSPATH') || exit;
/** /**
* One area of the design system for DesignChange (spec §2, §4): colors, variables, presets or theme options – its * 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 * 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). * read-back is judged. write() applies a planned change, restore() writes a snapshot back (full replace); both throw
* on failure. verify() judges the structure (entries, defaults), differences() the values Divi changed.
*/ */
interface DesignArea interface DesignArea
{ {
@@ -32,10 +33,28 @@ interface DesignArea
public function write(array $raw, bool $mayRemove): array; public function write(array $raw, bool $mayRemove): array;
/** /**
* Whether Divi stored what was written – sanitizing may change values, never which entries exist. * Writes $raw back as the whole inventory (full replace: entries missing in $raw are removed).
*
* @param array<array-key, mixed> $raw A snapshot.
* @return array<array-key, mixed> The inventory as read back after writing.
*/
public function restore(array $raw): array;
/**
* Whether Divi stored the entries and defaults that were written – sanitizing may change values, never which
* entries exist.
* *
* @param array<array-key, mixed> $expected * @param array<array-key, mixed> $expected
* @param array<array-key, mixed> $actual * @param array<array-key, mixed> $actual
*/ */
public function verify(array $expected, array $actual): bool; public function verify(array $expected, array $actual): bool;
/**
* German warnings for entries whose values Divi changed while saving (e.g. sanitized settings).
*
* @param array<array-key, mixed> $expected
* @param array<array-key, mixed> $actual
* @return list<string>
*/
public function differences(array $expected, array $actual): array;
} }
@@ -136,7 +136,7 @@ final class DesignChange
return ToolResult::ok($planned->summary) return ToolResult::ok($planned->summary)
->snapshotId($snapshotId) ->snapshotId($snapshotId)
->logId($logId) ->logId($logId)
->warnings($planned->warnings) ->warnings(array_merge($planned->warnings, $area->differences($planned->raw, $saved)))
->with('diff', $planned->diff) ->with('diff', $planned->diff)
->with('design_rev', $area->rev($saved)) ->with('design_rev', $area->rev($saved))
->toArray(); ->toArray();
@@ -203,7 +203,7 @@ final class DesignChange
Throwable $cause Throwable $cause
): never { ): never {
$reason = sprintf('Die %s konnten nicht gespeichert werden.', $area->label()); $reason = sprintf('Die %s konnten nicht gespeichert werden.', $area->label());
$failures = Rollback::attempt(static fn () => $area->write($current, true)); $failures = Rollback::attempt(static fn () => $area->restore($current));
if ($failures === []) { if ($failures === []) {
throw WriteFailed::rolledBack( throw WriteFailed::rolledBack(
$reason, $reason,
+21 -1
View File
@@ -9,7 +9,7 @@ defined('ABSPATH') || exit;
/** /**
* The area "presets" (spec §6): the inventory in Divi's stored format (PresetBook), written as REST format through * 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 * DesignSystemWriter::savePresets(). Divi's prepare_data() may sanitize values; verify() only insists on the same
* presets and defaults (PresetDiff without "changed"). * presets and defaults (PresetDiff without "changed"), differences() names the presets whose values changed.
*/ */
final class PresetArea implements DesignArea final class PresetArea implements DesignArea
{ {
@@ -43,6 +43,11 @@ final class PresetArea implements DesignArea
->data(); ->data();
} }
public function restore(array $raw): array
{
return $this->write($raw, true);
}
public function verify(array $expected, array $actual): bool public function verify(array $expected, array $actual): bool
{ {
$structural = array_filter( $structural = array_filter(
@@ -52,4 +57,19 @@ final class PresetArea implements DesignArea
return $structural === []; return $structural === [];
} }
public function differences(array $expected, array $actual): array
{
$warnings = [];
foreach (PresetDiff::between(PresetBook::fromDivi($expected), PresetBook::fromDivi($actual)) as $row) {
if ($row['action'] === 'changed') {
$warnings[] = sprintf(
'Divi hat die Einstellungen von Preset „%s“ beim Speichern bereinigt.',
$row['name']
);
}
}
return $warnings;
}
} }
@@ -22,14 +22,27 @@ use RuntimeException;
final class DesignChangeTest extends TestCase 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 * A DesignArea over an array; the first $failWrites calls of write()/restore() throw, $differences are the
{ * value warnings of differences().
return new class ($stored, $failWrites, $verify) implements DesignArea { */
private static function area(
array &$stored,
int $failWrites = 0,
bool $verify = true,
array $differences = []
): DesignArea {
return new class ($stored, $failWrites, $verify, $differences) implements DesignArea {
public array $writes = []; public array $writes = [];
public function __construct(private array &$stored, private int $failWrites, private bool $verify) public array $restores = [];
{
public function __construct(
private array &$stored,
private int $failWrites,
private bool $verify,
private array $differences
) {
} }
public function name(): string public function name(): string
@@ -64,10 +77,27 @@ final class DesignChangeTest extends TestCase
return $raw; return $raw;
} }
public function restore(array $raw): array
{
$this->restores[] = $raw;
if ($this->failWrites > 0) {
--$this->failWrites;
throw new RuntimeException('divi refused the restore');
}
$this->stored = $raw;
return $raw;
}
public function verify(array $expected, array $actual): bool public function verify(array $expected, array $actual): bool
{ {
return $this->verify; return $this->verify;
} }
public function differences(array $expected, array $actual): array
{
return $this->differences;
}
}; };
} }
@@ -178,6 +208,26 @@ final class DesignChangeTest extends TestCase
self::assertSame('presets-1', $log->records[0]['snapshot_id']); self::assertSame('presets-1', $log->records[0]['snapshot_id']);
} }
/** Final review I-3b: values Divi changed while saving are reported as warnings. */
public function testValuesDiviChangedWhileSavingAreWarnings(): void
{
$stored = ['v' => 1];
$area = self::area($stored, 0, true, ['Divi hat die Einstellungen von Preset „CTA“ beim Speichern bereinigt.']);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('presets-1');
$snapshots->expects('attachLog');
$result = (new DesignChange($snapshots, new FakeChangeLog(9)))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev($stored)],
self::plan(['v' => 2])
);
self::assertTrue($result['ok']);
self::assertContains('Divi hat die Einstellungen von Preset „CTA“ beim Speichern bereinigt.', $result['warnings']);
}
public function testLockedDiviWritesNoSnapshot(): void public function testLockedDiviWritesNoSnapshot(): void
{ {
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.16.0')); ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.16.0'));
@@ -234,7 +284,8 @@ final class DesignChangeTest extends TestCase
self::assertSame('presets-1', $e->details['snapshot_id']); self::assertSame('presets-1', $e->details['snapshot_id']);
} }
self::assertSame([[['v' => 2], false], [['v' => 1], true]], $area->writes); self::assertSame([[['v' => 2], false]], $area->writes);
self::assertSame([['v' => 1]], $area->restores, 'the rollback writes the snapshot back with restore()');
self::assertSame(['v' => 1], $stored); self::assertSame(['v' => 1], $stored);
} }
@@ -43,4 +43,32 @@ final class PresetAreaTest extends TestCase
self::assertFalse($area->verify($expected, $missing)); self::assertFalse($area->verify($expected, $missing));
self::assertFalse($area->verify($expected, $otherDefault)); self::assertFalse($area->verify($expected, $otherDefault));
} }
/** Final review I-3a: a snapshot is written back with full-replace semantics. */
public function testRestoreReplacesTheWholeInventory(): void
{
$writer = Mockery::mock(DesignSystemWriter::class);
$stored = PresetBookTest::stored();
$writer->expects('savePresets')->with(PresetBook::fromDivi($stored)->toRest(), true)->andReturn($stored);
self::assertSame(PresetBook::fromDivi($stored)->data(), (new PresetArea($writer))->restore($stored));
}
/** Final review I-3b: values Divi changed while saving, by preset name; structure is verify()'s job. */
public function testDifferencesNameThePresetsDiviSanitized(): 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']);
self::assertSame([], $area->differences($expected, $expected));
self::assertSame([], $area->differences($expected, $missing));
self::assertSame(
['Divi hat die Einstellungen von Preset „CTA primär“ beim Speichern bereinigt.'],
$area->differences($expected, $sanitized)
);
}
} }