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); } 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 => new ReleaseStatus(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 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); } }