Files
Jan-Niclas LoosenandClaude Opus 5.5 b511ef6348 refactor(compat): Prüfbausteine nach src/Compatibility, Matrix und ManifestCheck generisch
Requirement, Call, ConstantRequirement, Mirror, Problem, FeatureStatus, CompatibilityMatrix, Manifest, ManifestCheck sowie Fingerprints und LineDiff liegen jetzt im divi-freien Kern. Neu: GlobalRequirement, CompatibilityMatrix::fromProblems(), ManifestCheck-Option signatures, DiviManifestCheck::create() und DiviAreas. Divi-Verhalten unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:03:30 +01:00

532 lines
20 KiB
PHP

<?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\DesignLock;
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\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Support\LockTable;
use LIT\MCP\Tests\Unit\TestCase;
use Brain\Monkey\Functions;
use Mockery;
use RuntimeException;
final class DesignChangeTest extends TestCase
{
private LockTable $table;
protected function setUp(): void
{
parent::setUp();
$this->table = LockTable::install();
}
protected function tearDown(): void
{
unset($GLOBALS['wpdb']);
parent::tearDown();
}
/**
* A DesignArea over an array; the first $failWrites calls of write()/restore() throw, $differences are the
* value warnings of differences().
*/
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 $restores = [];
/** @var list<string> refresh()/raw() in the order they happened */
public array $calls = [];
public function __construct(
private array &$stored,
private int $failWrites,
private bool $verify,
private array $differences
) {
}
public function name(): string
{
return 'presets';
}
public function label(): string
{
return 'Presets';
}
public function feature(): DiviFeature
{
return DiviFeature::PresetWrite;
}
public function refresh(): void
{
$this->calls[] = 'refresh';
}
public function raw(): array
{
$this->calls[] = 'raw';
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 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
{
return $this->verify;
}
public function differences(array $expected, array $actual): array
{
return $this->differences;
}
};
}
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);
}
/** Final review M-9: confirm without design_rev is a missing input, not a stale one. */
public function testConfirmWithoutDesignRevIsInvalidInput(): 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], self::plan(['v' => 2]));
$details = self::assertToolError($result, 'validation_failed');
self::assertSame('design_rev fehlt; nichts wurde gespeichert.', $details['message']);
self::assertSame('aus dem Probelauf übernehmen', $details['fields']['design_rev']);
self::assertSame([], $area->writes);
}
/** Audit L5 (re-review): under the lock Divi's request caches are dropped before the inventory is read. */
public function testTheAreaDropsItsCachesUnderTheLockBeforeReading(): void
{
$stored = ['v' => 1];
$area = self::area($stored);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->allows('save')->andReturn('presets-1');
$snapshots->allows('attachLog');
(new DesignChange($snapshots, new FakeChangeLog(1), new DesignLock()))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev($stored)],
self::plan(['v' => 2])
);
self::assertSame(['refresh', 'raw'], array_slice($area->calls, 0, 2));
}
/** Final review I-5: a confirm while another change of the area runs writes nothing. */
public function testAHeldLockRefusesTheConfirm(): void
{
$this->table->rows['lit_mcp_lock_presets'] = time() . ':foreign';
$stored = ['v' => 1];
$area = self::area($stored);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->shouldNotReceive('save');
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden(), new DesignLock()))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev($stored)],
self::plan(['v' => 2])
);
self::assertToolError($result, 'conflict');
self::assertSame(409, $result->get_error_data()['status']);
self::assertStringContainsString('andere Änderung an den Presets', $result->get_error_message());
self::assertSame([], $area->writes);
self::assertArrayHasKey('lit_mcp_lock_presets', $this->table->rows, 'a foreign lock is not released');
}
public function testTheLockIsHeldWhileWritingAndReleasedAfterSuccess(): void
{
$stored = ['v' => 1];
$area = self::area($stored);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturnUsing(function (): string {
self::assertArrayHasKey('lit_mcp_lock_presets', $this->table->rows, 'snapshot under the lock');
return 'presets-1';
});
$snapshots->expects('attachLog')->andReturnUsing(function (): void {
self::assertArrayHasKey('lit_mcp_lock_presets', $this->table->rows, 'log under the lock');
});
$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::assertArrayNotHasKey('lit_mcp_lock_presets', $this->table->rows);
}
public function testTheLockIsReleasedAfterAFailedWrite(): 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) {
self::assertArrayNotHasKey('lit_mcp_lock_presets', $this->table->rows);
}
}
/** The design_rev is checked against the inventory read under the lock, not before it. */
public function testTheRevIsCheckedOnTheInventoryReadUnderTheLock(): void
{
$stored = ['v' => 1];
$area = self::area($stored);
$rev = $area->rev($stored);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->shouldNotReceive('save');
$this->table->beforeInsert = static function () use (&$stored): void {
$stored = ['v' => 'parallel'];
};
$result = (new DesignChange($snapshots, FakeChangeLog::forbidden()))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $rev],
self::plan(['v' => 2])
);
self::assertToolError($result, 'rev_mismatch');
self::assertSame([], $area->writes);
self::assertArrayNotHasKey('lit_mcp_lock_presets', $this->table->rows);
}
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']);
}
/** 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
{
ReleaseGate::useResolver(
static fn (): ReleaseStatus => ReleaseMatrices::uniform(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 testTheLockFollowsTheFeatureOfTheArea(): void
{
$others = array_values(array_filter(
DiviFeature::cases(),
static fn (DiviFeature $feature): bool => $feature !== DiviFeature::PresetWrite
));
ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status($others));
$stored = ['v' => 1];
$area = self::area($stored);
$snapshots = Mockery::mock(SnapshotStore::class);
$snapshots->expects('save')->andReturn('s1');
$snapshots->expects('attachLog');
$written = (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($written['ok'], 'every other feature locked: presets are still written');
ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseMatrices::status([DiviFeature::PresetWrite]));
try {
(new DesignChange(Mockery::mock(SnapshotStore::class), FakeChangeLog::forbidden()))->run(
'lit/divi-presets-set',
$area,
['confirm' => true, 'design_rev' => $area->rev($stored)],
self::plan(['v' => 3])
);
self::fail('preset-write locked must refuse');
} catch (DiviLocked $locked) {
self::assertSame([DiviFeature::PresetWrite], $locked->features);
}
}
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]], $area->writes);
self::assertSame([['v' => 1]], $area->restores, 'the rollback writes the snapshot back with restore()');
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);
}
}