'font-body', 'preset' => 'Lesetext']; private const RADIUS = 'gvid-radius0001'; private const CLAIM = 'gvid-claim00001'; protected function setUp(): void { parent::setUp(); Functions\when('current_user_can')->justReturn(true); // DesignLock (final review I-5): the lock is always free here. LockTable::install(); } private function ability( PageRewriter $pages, ?DesignSystemWriter $writer = null, ?SnapshotStore $snapshots = null, ?FakeChangeLog $log = null, ?UsageReport $report = null, bool $unfiltered = true, ?GlobalVariablesAdapter $variables = null ): DiviDesignDetachAbility { $usages = Mockery::mock(GlobalUsages::class); $usages->allows('reportFor')->andReturn($report ?? new UsageReport(0, [], 0, 0, [], 0, false)); return new DiviDesignDetachAbility( $writer ?? self::readOnlyWriter(), $variables ?? self::variableAdapter(), new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), $log ?? FakeChangeLog::forbidden()), $usages, $pages, static fn (): bool => $unfiltered ); } /** * Reads $stored (default PresetBookTest::stored()), never saves. * * @param array|null $stored */ private static function readOnlyWriter(?array $stored = null): DesignSystemWriter&MockInterface { $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('presets')->andReturn($stored ?? PresetBookTest::stored()); $writer->allows('canUseVisualBuilder')->andReturn(true); $writer->expects('savePresets')->never(); return $writer; } /** Reads variables() (the Variablen-Verwaltung is allowed unless $manage is false), never saves. */ private static function variableAdapter(bool $manage = true, string $radius = '8px'): GlobalVariablesAdapter&MockInterface { $variables = Mockery::mock(GlobalVariablesAdapter::class); $variables->allows('read')->andReturn(self::variables($radius)); $variables->allows('canManage')->andReturn($manage); $variables->expects('save')->never(); return $variables; } /** @return array>> Global variables in Divi's stored form. */ private static function variables(string $radius = '8px'): array { return [ 'numbers' => [self::RADIUS => [ 'id' => self::RADIUS, 'label' => 'Eckenradius', 'value' => $radius, 'order' => '1', 'status' => 'active', 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => 'numbers', ]], // Archived or not does not matter for a detach (V9). 'strings' => [self::CLAIM => [ 'id' => self::CLAIM, 'label' => 'Claim', 'value' => 'Gut beraten', 'order' => '', 'status' => 'archived', 'lastUpdated' => '2026-10-01T10:00:00.000Z', 'variableType' => 'strings', ]], ]; } /** @return array PresetBookTest::stored() with the radius variable in preset "CTA primär". */ private static function storedWithVariable(): array { $stored = PresetBookTest::stored(); $radius = ['topLeft' => VariableDetachRewriteTest::ref(self::RADIUS)]; $stored['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = ['button' => ['decoration' => [ 'border' => ['desktop' => ['value' => ['radius' => $radius]]], ]]]; return $stored; } /** Matches the reference query of a preview()/apply() call: it finds $id and nothing else. */ private static function referencing(string $id): Closure { return static fn (ReferenceUsageQuery $query): bool => $query->matches( '' ) && ! $query->matches(''); } /** Matches a VariableDetachRewrite strategy that turns a whole reference to $id into $expected. */ private static function resolving(string $id, string $expected, array $settings = []): Closure { return static function (Closure $rewrite) use ($id, $expected, $settings): bool { $strategy = $rewrite(); $block = Block::create('divi/text', ['x' => VariableDetachRewriteTest::ref($id, $settings)]); return $strategy instanceof VariableDetachRewrite && $strategy->rewrite($block) === ['x' => $expected]; }; } private static function untouched(): PageRewriter&MockInterface { $pages = Mockery::mock(PageRewriter::class); $pages->expects('preview')->never(); $pages->expects('apply')->never(); return $pages; } /** Matches the explicit-only query and a DetachRewrite strategy of a preview()/apply() call. */ private static function explicitQuery(): Closure { return static function (PresetUsageQuery $query): bool { $targets = (new ReflectionProperty(PresetUsageQuery::class, 'targets'))->getValue($query); return array_column($targets, 'default') === [false]; }; } private static function detaching(): Closure { return static fn (Closure $rewrite): bool => $rewrite() instanceof DetachRewrite; } public function testNeedsEditThemeOptionsAndTheVisualBuilder(): void { $ability = $this->ability(self::untouched()); Functions\when('current_user_can')->justReturn(false); self::assertToolError($ability->permission(['preset' => self::LESETEXT]), 'forbidden'); self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'forbidden'); } public function testAVariableNeedsTheVariableRightsToo(): void { $ability = $this->ability(self::untouched(), null, null, null, null, true, self::variableAdapter(false)); self::assertToolError($ability->permission(['variable' => 'Eckenradius']), 'forbidden'); self::assertToolError($ability->execute(['variable' => 'Eckenradius']), 'forbidden'); self::assertTrue($ability->permission(['preset' => self::LESETEXT])); } public function testUnknownAmbiguousAndColorVariables(): void { $ability = $this->ability(self::untouched()); $unknown = self::assertToolError($ability->execute(['variable' => 'Fehlt']), 'not_found'); self::assertStringContainsString('lit/divi-colors-set', $unknown['hint']); $color = self::assertToolError($ability->execute(['variable' => 'gcid-primary-color']), 'unsupported'); self::assertStringContainsString('nur deaktivieren', $color['hint']); self::assertArrayHasKey('variable', self::errorFields($ability->execute(['variable' => ' ']))); $both = $ability->execute(['variable' => 'Eckenradius', 'preset' => self::LESETEXT]); self::assertSame('entweder preset oder variable angeben, nicht beides', self::errorFields($both)['variable']); } public function testVariableDryRunListsPagesAndThePresetsThatUseIt(): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('preview') ->with( Mockery::on(self::referencing(self::RADIUS)), Mockery::on(self::resolving(self::RADIUS, '8px')), null ) ->andReturn(PageRewriteReportTest::preview(1)); $pages->expects('apply')->never(); $ability = $this->ability($pages, self::readOnlyWriter(self::storedWithVariable())); $dry = self::assertToolError($ability->execute(['variable' => 'eckenradius']), 'confirm_required'); self::assertSame(12, $dry['pages'][0]['post_id']); self::assertSame([['aaaaaaaaaa', 'changed']], array_map( static fn (array $row): array => [$row['id'], $row['action']], $dry['diff'] )); self::assertSame(PresetBook::fromDivi(self::storedWithVariable())->rev(), $dry['design_rev']); self::assertStringStartsWith('Entkoppeln von Variable „Eckenradius“ – betroffen', $dry['message']); self::assertStringContainsString('die Verweise in 1 Presets (diff, mit Snapshot)', $dry['message']); } public function testAStringsVariableKeepsBeforeAndAfterAndArchivedOnesWork(): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('preview') ->with( Mockery::on(self::referencing(self::CLAIM)), Mockery::on(self::resolving(self::CLAIM, '„Gut beraten“', ['before' => '„', 'after' => '“'])), null ) ->andReturn(PageRewriteReportTest::preview(1)); $dry = self::assertToolError($this->ability($pages)->execute(['variable' => self::CLAIM]), 'confirm_required'); self::assertArrayNotHasKey('diff', $dry, 'no preset uses the claim'); } public function testVariableConfirmResolvesThePresetsFirstThenThePages(): void { $calls = []; $stored = self::storedWithVariable(); $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('presets')->andReturn($stored); $writer->allows('canUseVisualBuilder')->andReturn(true); $writer->expects('savePresets') ->with( Mockery::on(static fn (array $rest): bool => ($rest['module'][0]['items'][0]['attrs']['button'] ['decoration']['border']['desktop']['value']['radius']['topLeft'] ?? null) === '8px'), false ) ->andReturnUsing(static function () use (&$calls, $stored): array { $calls[] = 'presets'; return PresetBook::fromDivi($stored)->withVariableResolved(self::RADIUS, '8px')->data(); }); $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->expects('save')->with('presets', $stored, 'lit/divi-design-detach')->andReturn('presets-5'); $snapshots->expects('attachLog'); $pages = Mockery::mock(PageRewriter::class); $pages->expects('apply') ->with( 'lit/divi-design-detach', Mockery::on(self::referencing(self::RADIUS)), Mockery::on(self::resolving(self::RADIUS, '8px')), null, 'Entkoppeln von Variable „Eckenradius“' ) ->andReturnUsing(static function () use (&$calls): array { $calls[] = 'pages'; return PageRewriteReportTest::applied(1, 0); }); $ability = $this->ability($pages, $writer, $snapshots, new FakeChangeLog(9)); $result = $ability->execute([ 'variable' => self::RADIUS, 'confirm' => true, 'design_rev' => PresetBook::fromDivi($stored)->rev(), ]); self::assertSame(['presets', 'pages'], $calls); self::assertSame('presets-5', $result['snapshot_id']); self::assertSame('aaaaaaaaaa', $result['diff'][0]['id']); self::assertStringStartsWith('Presets: 1 geändert.', $result['summary']); self::assertStringEndsWith( 'Jetzt kann lit/divi-variables-set die Variable archivieren (status: archived).', $result['summary'] ); } /** @return array */ public static function unsafeValues(): array { return [ 'empty' => ['', true], 'marker' => ['$variable({"type":"content"})$', true], 'end marker' => ['x)$', true], 'markup without unfiltered_html' => ['8px', false], ]; } /** @dataProvider unsafeValues */ public function testAValueThatMustNotBeInlinedRefusesTheCall(string $value, bool $unfiltered): void { $ability = $this->ability( self::untouched(), null, null, null, null, $unfiltered, self::variableAdapter(true, $value) ); $error = self::assertToolError($ability->execute(['variable' => 'Eckenradius']), 'unsupported'); self::assertStringContainsString('Divi-Builder', $error['hint']); } public function testMarkupInTheValueIsAllowedWithUnfilteredHtml(): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('preview')->andReturn(PageRewriteReportTest::preview(1)); $ability = $this->ability($pages, null, null, null, null, true, self::variableAdapter(true, '8px')); self::assertToolError($ability->execute(['variable' => 'Eckenradius']), 'confirm_required'); } public function testReferencesLeftOnPagesNeverGetTheDoneHint(): void { $preview = PageRewriteReportTest::preview(0); $preview['skipped'] = [['post_id' => 12, 'reason' => 's0.r0.c0.m0: Die Variable steht hier eingebettet']]; $pages = Mockery::mock(PageRewriter::class); $pages->expects('preview')->andReturn($preview); $result = $this->ability($pages)->execute(['variable' => 'Eckenradius']); self::assertStringNotContainsString('lit/divi-variables-set', $result['summary']); self::assertStringNotContainsString('Keine Verweise', $result['summary']); self::assertSame($preview['skipped'], $result['skipped']); } public function testEmbeddedReferencesInPresetsAreReportedAndWithholdTheDoneHint(): void { $stored = PresetBookTest::stored(); $stored['module']['divi/button']['items']['aaaaaaaaaa']['attrs'] = ['button' => ['decoration' => [ 'border' => ['desktop' => ['value' => ['radius' => 'Ab ' . VariableDetachRewriteTest::ref(self::RADIUS) . ' x']]], ]]]; $pages = Mockery::mock(PageRewriter::class); $pages->expects('preview')->andReturn(PageRewriteReportTest::preview(0)); $result = $this->ability($pages, self::readOnlyWriter($stored))->execute(['variable' => 'Eckenradius']); self::assertStringNotContainsString('lit/divi-variables-set', $result['summary']); self::assertStringContainsString('In 1 Presets steht die Variable eingebettet', $result['warnings'][0]); self::assertStringContainsString('CTA primär', $result['warnings'][0]); } public function testVariablePostIdsSkipThePresetStep(): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('apply') ->with('lit/divi-design-detach', Mockery::any(), Mockery::any(), [12], Mockery::type('string')) ->andReturn(PageRewriteReportTest::applied(1, 0)); $ability = $this->ability($pages, self::readOnlyWriter(self::storedWithVariable())); $result = $ability->execute(['variable' => 'Eckenradius', 'post_ids' => [12], 'confirm' => true]); self::assertSame([], $result['diff']); self::assertNull($result['snapshot_id']); self::assertStringNotContainsString('lit/divi-variables-set', $result['summary']); } public function testNoVariableReferenceLeftPointsToArchiving(): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('preview')->andReturn(PageRewriteReportTest::preview(0)); $result = $this->ability($pages)->execute(['variable' => 'Eckenradius']); self::assertSame( 'Keine Verweise auf Variable „Eckenradius“ gefunden – nichts zu tun. Jetzt kann lit/divi-variables-set ' . 'die Variable archivieren (status: archived).', $result['summary'] ); } public function testFeaturesPerBranch(): void { $ability = $this->ability(self::untouched()); self::assertNotContains(DiviFeature::VariableWrite, $ability->diviFeatures(['preset' => self::LESETEXT])); self::assertContains(DiviFeature::VariableWrite, $ability->diviFeatures(['variable' => 'Eckenradius'])); self::assertContains(DiviFeature::PresetWrite, $ability->diviFeatures(['variable' => 'Eckenradius'])); } public function testUnknownAndAmbiguousPresets(): void { $ability = $this->ability(self::untouched()); $unknown = $ability->execute(['preset' => ['module' => 'button', 'preset' => 'Fehlt']]); self::assertToolError($unknown, 'not_found'); $ambiguous = $ability->execute(['preset' => ['module' => 'button', 'preset' => 'Doppelt']]); self::assertToolError($ambiguous, 'validation_failed'); self::assertStringContainsString('bbbbbbbbbb, cccccccccc', self::errorFields($ambiguous)['preset.preset']); $form = $ability->execute(['preset' => ['preset' => 'Lesetext']]); self::assertArrayHasKey('preset', self::errorFields($form)); } public function testDryRunListsPagesAndTheDiffOfTheNestingPresets(): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('preview') ->with(Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), null) ->andReturn(PageRewriteReportTest::preview(1)); $pages->expects('apply')->never(); $dry = self::assertToolError($this->ability($pages)->execute(['preset' => self::LESETEXT]), 'confirm_required'); self::assertSame(12, $dry['pages'][0]['post_id']); self::assertSame([['dddddddddd', 'changed']], array_map( static fn (array $row): array => [$row['id'], $row['action']], $dry['diff'] )); self::assertSame(PresetBook::fromDivi(PresetBookTest::stored())->rev(), $dry['design_rev']); self::assertStringContainsString('design_rev', $dry['message']); self::assertSame([], $dry['warnings']); } public function testConfirmWithAStaleDesignRevWritesNeitherPresetsNorPages(): void { $error = self::assertToolError( $this->ability(self::untouched())->execute([ 'preset' => self::LESETEXT, 'confirm' => true, 'design_rev' => 'veraltet', ]), 'rev_mismatch' ); self::assertStringContainsString('nichts wurde gespeichert', $error['message']); } public function testConfirmRemovesTheNestedReferencesFirstThenThePages(): void { $calls = []; $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('presets')->andReturn(PresetBookTest::stored()); $writer->allows('canUseVisualBuilder')->andReturn(true); $writer->expects('savePresets') ->with( Mockery::on(static fn (array $rest): bool => $rest['module'][1]['items'][0]['groupPresets'] === []), false ) ->andReturnUsing(static function () use (&$calls): array { $calls[] = 'presets'; return PresetBook::fromDivi(PresetBookTest::stored())->withoutNestedRef('gggggggggg')->data(); }); $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->expects('save')->andReturn('presets-4'); $snapshots->expects('attachLog'); $pages = Mockery::mock(PageRewriter::class); $pages->expects('preview')->andReturn(PageRewriteReportTest::preview(1)); $pages->expects('apply') ->with( 'lit/divi-design-detach', Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), null, 'Entkoppeln von Gruppen-Preset divi/font-body „Lesetext“' ) ->andReturnUsing(static function () use (&$calls): array { $calls[] = 'pages'; return PageRewriteReportTest::applied(1, 0); }); $ability = $this->ability($pages, $writer, $snapshots, new FakeChangeLog(7)); $dry = self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'confirm_required'); $result = $ability->execute([ 'preset' => self::LESETEXT, 'confirm' => true, 'design_rev' => $dry['design_rev'], ]); self::assertSame(['presets', 'pages'], $calls); self::assertSame('presets-4', $result['snapshot_id']); self::assertSame(7, $result['log_id']); self::assertSame('dddddddddd', $result['diff'][0]['id']); self::assertSame(12, $result['saved'][0]['post_id']); self::assertStringStartsWith('Presets: 1 geändert.', $result['summary']); self::assertStringEndsWith('Jetzt kann lit/divi-presets-delete das Preset löschen.', $result['summary']); } public function testAPageErrorAfterThePresetStepSaysThePresetsAreChanged(): void { $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('presets')->andReturn(PresetBookTest::stored()); $writer->allows('canUseVisualBuilder')->andReturn(true); $writer->expects('savePresets') ->andReturn(PresetBook::fromDivi(PresetBookTest::stored())->withoutNestedRef('gggggggggg')->data()); $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->expects('save')->andReturn('presets-4'); $snapshots->expects('attachLog'); $pages = Mockery::mock(PageRewriter::class); $pages->expects('apply')->andReturn(Errors::partialWrite('Seite 13 nicht gespeichert.', 13)); $rev = PresetBook::fromDivi(PresetBookTest::stored())->rev(); $error = self::assertToolError( $this->ability($pages, $writer, $snapshots, new FakeChangeLog(7))->execute([ 'preset' => self::LESETEXT, 'confirm' => true, 'design_rev' => $rev, ]), 'partial_write' ); self::assertStringContainsString('bereits entfernt (Snapshot presets-4)', $error['message']); self::assertSame('presets-4', $error['snapshot_id']); self::assertSame(13, $error['post_id']); } public function testPostIdsTouchOnlyThePages(): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('preview') ->with(Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), [12]) ->andReturn(PageRewriteReportTest::preview(1)); $pages->expects('apply') ->with('lit/divi-design-detach', Mockery::any(), Mockery::any(), [12], Mockery::type('string')) ->andReturn(PageRewriteReportTest::applied(1, 0)); $ability = $this->ability($pages); $input = ['preset' => self::LESETEXT, 'post_ids' => [12]]; $dry = self::assertToolError($ability->execute($input), 'confirm_required'); self::assertArrayNotHasKey('diff', $dry); $result = $ability->execute($input + ['confirm' => true]); self::assertSame([], $result['diff']); self::assertNull($result['design_rev']); self::assertNull($result['snapshot_id']); self::assertStringNotContainsString('lit/divi-presets-delete', $result['summary']); } public function testADefaultPresetLosesOnlyItsExplicitReferences(): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('preview') ->with(Mockery::on(self::explicitQuery()), Mockery::on(self::detaching()), null) ->andReturn(PageRewriteReportTest::preview(1)); $dry = self::assertToolError( $this->ability($pages)->execute(['preset' => ['module' => 'text', 'preset' => 'Fließtext']]), 'confirm_required' ); self::assertSame( ['„Fließtext“ ist das Standard-Preset von divi/text: Divi wendet es weiter auf Module ohne eigenes ' . 'Preset an; entfernt werden nur die ausdrücklichen Verweise.'], $dry['warnings'] ); self::assertArrayNotHasKey('diff', $dry, 'module presets are never nested'); } public function testNothingLeftIsNoConfirmAndPointsToDelete(): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('preview')->andReturn(PageRewriteReportTest::preview(0)); $result = $this->ability($pages)->execute(['preset' => ['module' => 'button', 'preset' => 'CTA primär']]); self::assertTrue($result['ok']); self::assertSame( 'Keine Verweise auf Modul-Preset divi/button „CTA primär“ gefunden – nichts zu tun. Jetzt kann ' . 'lit/divi-presets-delete das Preset löschen.', $result['summary'] ); } public function testUnreadablePostsKeepTheDeleteHintBack(): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('apply')->andReturn(PageRewriteReportTest::applied(1, 0, [], 3)); $result = $this->ability($pages)->execute([ 'preset' => ['module' => 'button', 'preset' => 'CTA primär'], 'confirm' => true, ]); self::assertStringNotContainsString('lit/divi-presets-delete', $result['summary']); self::assertStringNotContainsString('erledigt', $result['summary']); self::assertStringContainsString('3 für diesen Zugang nicht sichtbare Fundstellen', $result['summary']); self::assertSame(3, $result['hidden_count']); } public function testNoUnreadablePostsKeepTheDeleteHint(): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('apply')->andReturn(PageRewriteReportTest::applied(1, 0)); $result = $this->ability($pages)->execute([ 'preset' => ['module' => 'button', 'preset' => 'CTA primär'], 'confirm' => true, ]); self::assertStringContainsString('lit/divi-presets-delete', $result['summary']); self::assertStringNotContainsString('nicht sichtbare', $result['summary']); } public function testRemainingPagesAskForAnotherCallWithoutTheDeleteHint(): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('apply')->andReturn(PageRewriteReportTest::applied(2, 2)); $result = $this->ability($pages)->execute([ 'preset' => ['module' => 'button', 'preset' => 'CTA primär'], 'confirm' => true, ]); self::assertSame(2, $result['remaining']); self::assertStringContainsString('Noch 2 Seiten offen', $result['summary']); self::assertStringNotContainsString('lit/divi-presets-delete', $result['summary']); } /** Saves the preset step once (snapshot presets-4, log 7); for page failures after it. */ private function abilityAfterThePresetStep(PageRewriter $pages): DiviDesignDetachAbility { $writer = Mockery::mock(DesignSystemWriter::class); $writer->allows('presets')->andReturn(PresetBookTest::stored()); $writer->allows('canUseVisualBuilder')->andReturn(true); $writer->expects('savePresets') ->andReturn(PresetBook::fromDivi(PresetBookTest::stored())->withoutNestedRef('gggggggggg')->data()); $snapshots = Mockery::mock(SnapshotStore::class); $snapshots->expects('save')->andReturn('presets-4'); $snapshots->expects('attachLog'); return $this->ability($pages, $writer, $snapshots, new FakeChangeLog(7)); } /** @return array The confirmed nested call with the current design_rev. */ private static function confirmedLesetext(?array $stored = null): array { return [ 'preset' => self::LESETEXT, 'confirm' => true, 'design_rev' => PresetBook::fromDivi($stored ?? PresetBookTest::stored())->rev(), ]; } /** @return array */ public static function firstPageFailures(): array { return [ 'rolled back' => [WriteFailed::rolledBack('Seite 12 nicht gespeichert.'), 'write_failed'], 'Divi locked' => [new DiviLocked( ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0'), [DiviFeature::PageWrite] ), 'divi_untested'], 'Divi locked inside a rollback' => [ WriteFailed::rolledBack( 'Seite 12 nicht gespeichert.', new DiviLocked(ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0'), [DiviFeature::PageWrite]) ), 'divi_untested', ], 'partial' => [WriteFailed::partial('Seite 12 nicht gespeichert.', postId: 12), 'partial_write'], ]; } /** @dataProvider firstPageFailures */ public function testAFirstPageFailureAfterThePresetStepSaysThePresetsAreChanged( RuntimeException $failure, string $code ): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('apply')->andThrow($failure); // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. $log = (string) tempnam(sys_get_temp_dir(), 'lit'); $previous = ini_set('error_log', $log); try { $result = $this->abilityAfterThePresetStep($pages)->execute(self::confirmedLesetext()); $logged = (string) file_get_contents($log); } finally { ini_set('error_log', (string) $previous); unlink($log); } $error = self::assertToolError($result, $code); self::assertStringContainsString('lit/divi-design-detach failed', $logged); self::assertStringContainsString('bereits entfernt (Snapshot presets-4)', $error['message']); self::assertStringNotContainsString('Nichts wurde gespeichert', $error['message']); self::assertSame('presets-4', $error['snapshot_id']); self::assertSame('dddddddddd', $error['diff'][0]['id']); } /** @dataProvider firstPageFailures */ public function testAFirstPageFailureWithoutAPresetStepIsThrownAsBefore(RuntimeException $failure): void { $pages = Mockery::mock(PageRewriter::class); $pages->expects('apply')->andThrow($failure); $this->expectExceptionObject($failure); $this->ability($pages)->execute([ 'preset' => ['module' => 'button', 'preset' => 'CTA primär'], 'confirm' => true, ]); } /** @return array */ public static function blockingUsages(): array { return [ 'live page without edit_post' => [new UsageReport(1, [], 0, 1, [12], 0, false)], 'too many live pages' => [new UsageReport(600, [], 0, 600, [], 0, true)], ]; } /** * Ruling P8a: the preset plan carries the page usages, so a blocked live page refuses before the presets. * * @dataProvider blockingUsages */ public function testABlockedLivePageRefusesBeforeThePresetsAreWritten(UsageReport $report): void { $ability = $this->ability(self::untouched(), null, null, null, $report); $error = self::assertToolError($ability->execute(self::confirmedLesetext()), 'forbidden'); self::assertStringContainsString('nichts wurde gespeichert', $error['message']); } /** Final review I-2: the preset step saves the whole inventory, so HTML/CSS elsewhere needs unfiltered_html. */ public function testMarkupInOtherPresetsNeedsUnfilteredHtml(): void { $stored = PresetBookTest::storedWithMarkup(); $pages = Mockery::mock(PageRewriter::class); $pages->allows('preview')->andReturn(PageRewriteReportTest::preview(1)); $pages->expects('apply')->never(); $ability = $this->ability($pages, self::readOnlyWriter($stored), null, null, null, false); $dry = self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'forbidden'); $confirmed = self::assertToolError($ability->execute(self::confirmedLesetext($stored)), 'forbidden'); self::assertStringContainsString('unfiltered_html', $dry['message']); self::assertStringContainsString('unfiltered_html', $confirmed['message']); } public function testNestingWithoutADesignRevWritesNeitherPresetsNorPages(): void { $error = $this->ability(self::untouched())->execute(['preset' => self::LESETEXT, 'confirm' => true]); self::assertToolError($error, 'validation_failed'); self::assertArrayHasKey('design_rev', self::errorFields($error)); } public function testSchemaAndMeta(): void { $ability = $this->ability(self::untouched()); $schema = $ability->inputSchema(); self::assertSame('lit/divi-design-detach', $ability->name()); self::assertFalse($ability->isDirect()); self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => true], $ability->annotations()); self::assertFalse($schema['additionalProperties']); self::assertSame(['preset'], $schema['properties']['preset']['required']); self::assertArrayHasKey('design_rev', $schema['properties']); self::assertArrayHasKey('variable', $schema['properties']); self::assertSame(['saved', 'remaining', 'skipped'], array_slice($ability->outputSchema()['required'], -3)); self::assertStringContainsString('lit/divi-presets-delete', $ability->description()); } }