diff --git a/src/Integrations/Divi/Design/DiviDesignDetachAbility.php b/src/Integrations/Divi/Design/DiviDesignDetachAbility.php index 6d5aeeb..f4075d6 100644 --- a/src/Integrations/Divi/Design/DiviDesignDetachAbility.php +++ b/src/Integrations/Divi/Design/DiviDesignDetachAbility.php @@ -21,27 +21,32 @@ use WP_Error; defined('ABSPATH') || exit; /** - * lit/divi-design-detach (spec §6.8, ruling R10 – presets only): removes every explicit reference to one preset – - * from the modulePreset/groupPreset stacks of pages, library entries and Theme Builder layouts (DetachRewrite through - * the PageRewriter) and, for a group preset without post_ids, from the groupPresets of other presets (DesignChange, - * snapshot "presets"). Modules fall back to the next preset of their stack or the default. The dry run is ONE - * confirm_required with the pages and, when other presets nest it, the diff and design_rev of the preset step. - * Confirmed, the preset step runs first – its plan carries the page usages, so a blocked live post refuses before - * anything is written (P8a) – then at most PageRewriter::PAGE_LIMIT pages. A default preset keeps working implicitly - * (Divi applies it); only explicit references go. Variables follow with Etappe 4c. + * lit/divi-design-detach (spec §6.8, rulings R10, V9): removes every explicit reference to one preset or one global + * variable from pages, library entries and Theme Builder layouts (DetachRewrite or VariableDetachRewrite through the + * PageRewriter) and, without post_ids, from presets that nest it (DesignChange, snapshot "presets"): a group preset + * leaves the groupPresets of other presets, a variable reference in a preset becomes the variable's value. Modules + * fall back to the next preset of their stack or the default; a variable reference becomes the value (the variable + * itself stays as it is). The dry run is ONE confirm_required with the pages and, when presets nest the target, the + * diff and design_rev of the preset step. Confirmed, the preset step runs first – its plan carries the page usages, + * so a blocked live post refuses before anything is written (P8a) – then at most PageRewriter::PAGE_LIMIT pages. A + * default preset keeps working implicitly (Divi applies it); only explicit references go. Global colors have no + * detach (ruling V9: they are only deactivated). */ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures { - private const VARIABLES = 'Variablen folgen mit Etappe 4c.'; - private const DELETE_HINT = 'Jetzt kann lit/divi-presets-delete das Preset löschen.'; private const DEFAULT_NOTE = '„%s“ ist das Standard-Preset von %s: Divi wendet es weiter auf Module ohne eigenes ' . 'Preset an; entfernt werden nur die ausdrücklichen Verweise.'; + private const ARCHIVE_HINT = 'Jetzt kann lit/divi-variables-set die Variable archivieren (status: archived).'; + + private const NO_COLORS = 'Globale Farben lassen sich nicht entkoppeln, nur deaktivieren (lit/divi-colors-set).'; + /** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */ public function __construct( private DesignSystemWriter $writer, + private GlobalVariablesAdapter $variables, private DesignChange $change, private GlobalUsages $usages, private PageRewriter $pages, @@ -57,23 +62,26 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures public function label(): string { - return 'Divi-Preset-Verweise entfernen'; + return 'Divi-Preset- und Variablen-Verweise entfernen'; } public function description(): string { - return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) aus ' - . 'Seiten, Bibliothekseinträgen und Theme-Builder-Layouts und – bei Gruppen-Presets – aus anderen Presets; ' - . 'Module fallen auf das nächste Preset im Stapel bzw. das Standard-Preset zurück. post_ids beschränkt auf ' - . 'diese Seiten (Verweise in Presets bleiben dann). Zuerst Probelauf (pages; bei Verweisen in Presets diff ' - . 'und design_rev), nach Rückfrage beim Nutzer confirm: true (mit design_rev, wenn der Probelauf eine ' - . 'nennt); je Aufruf höchstens ' . PageRewriter::PAGE_LIMIT . ' Seiten, remaining nennt den Rest. Mit ' - . 'post_ids können nur die genannten Seiten das Schreiben blockieren und die Obergrenze für sehr viele ' - . 'Seiten gilt nicht; eine genannte veröffentlichte, private oder geplante Seite ohne Bearbeitungsrecht ' - . 'verweigert den ganzen Aufruf (forbidden, blocked_ids), eine genannte andere Seite ohne ' - . 'Bearbeitungsrecht steht in skipped (bei mehr als 2000 Live-Fundstellen entfällt die Vorabprüfung, ' - . 'dann auch eine genannte Live-Seite). Danach kann lit/divi-presets-delete das Preset löschen. ' - . self::VARIABLES; + return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) oder auf ' + . 'eine globale Variable (variable: Name oder ID) aus Seiten, Bibliothekseinträgen und ' + . 'Theme-Builder-Layouts und – bei Gruppen-Presets und Variablen – aus Presets; Module fallen auf das ' + . 'nächste Preset im Stapel bzw. das Standard-Preset zurück, ein Variablenverweis wird durch den aktuellen ' + . 'Wert der Variablen ersetzt (die Variable selbst bleibt; Verweise mitten in einem längeren Text bleiben ' + . 'stehen und stehen als Hinweis in warnings). post_ids beschränkt auf diese Seiten (Verweise in Presets ' + . 'bleiben dann). Zuerst Probelauf (pages; bei Verweisen in Presets diff und design_rev), nach Rückfrage ' + . 'beim Nutzer confirm: true (mit design_rev, wenn der Probelauf eine nennt); je Aufruf höchstens ' + . PageRewriter::PAGE_LIMIT . ' Seiten, remaining nennt den Rest. Mit post_ids können nur die genannten ' + . 'Seiten das Schreiben blockieren und die Obergrenze für sehr viele Seiten gilt nicht; eine genannte ' + . 'veröffentlichte, private oder geplante Seite ohne Bearbeitungsrecht verweigert den ganzen Aufruf ' + . '(forbidden, blocked_ids), eine genannte andere Seite ohne Bearbeitungsrecht steht in skipped (bei mehr ' + . 'als 2000 Live-Fundstellen entfällt die Vorabprüfung, dann auch eine genannte Live-Seite). Danach kann ' + . 'lit/divi-presets-delete das Preset löschen bzw. lit/divi-variables-set die Variable archivieren. ' + . self::NO_COLORS; } public function inputSchema(): array @@ -90,7 +98,10 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures ], 'required' => ['preset'], ], - 'variable' => ['type' => 'string', 'description' => self::VARIABLES], + 'variable' => [ + 'type' => 'string', + 'description' => 'Name oder ID einer globalen Variablen (statt preset). ' . self::NO_COLORS, + ], 'post_ids' => PostIdsInput::schema(), ] + DesignChange::inputProperties(), 'additionalProperties' => false, @@ -117,11 +128,12 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures /** * Like lit/divi-presets-assign (it rewrites every kind of post), plus the preset step that removes references - * nested in other presets (PresetWrite). + * nested in presets (PresetWrite); a variable is read and its rights checked through GlobalVariablesAdapter + * (VariableWrite). */ public function diviFeatures(array $input): array { - return [ + $features = [ DiviFeature::PageRead, DiviFeature::PageWrite, DiviFeature::Catalog, @@ -132,26 +144,25 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures DiviFeature::ThemeBuilderWrite, DiviFeature::Roles, ]; + + return array_key_exists('variable', $input) ? [...$features, DiviFeature::VariableWrite] : $features; } public function permission(array $input): bool|WP_Error { - return DesignRights::presets($this->writer) ?? true; + return $this->rights($input) ?? true; } public function execute(array $input): mixed { - $refusal = DesignRights::presets($this->writer); + $refusal = $this->rights($input); if ($refusal !== null) { return $refusal; } - if (array_key_exists('variable', $input)) { - return Errors::unsupported( - 'Verweise auf Variablen entfernt LIT MCP noch nicht; nichts wurde geändert.', - self::VARIABLES . ' Bis dahin nur Presets angeben (preset).' - ); + if (array_key_exists('variable', $input) && array_key_exists('preset', $input)) { + return self::invalid(['variable' => 'entweder preset oder variable angeben, nicht beides']); } - $target = $this->presetTarget($input); + $target = array_key_exists('variable', $input) ? $this->variableTarget($input) : $this->presetTarget($input); if ($target instanceof WP_Error) { return $target; } @@ -159,6 +170,88 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures return ($input['confirm'] ?? false) === true ? $this->confirmed($input, $target) : $this->dryRun($target); } + /** + * Spec §10: presets need the preset rights; a variable also the variable rights – the preset step writes presets + * either way (V9). + * + * @param array $input + */ + private function rights(array $input): ?WP_Error + { + $refusal = DesignRights::presets($this->writer); + if ($refusal !== null || ! array_key_exists('variable', $input)) { + return $refusal; + } + + return DesignRights::variables($this->writer, $this->variables); + } + + /** + * The global variable to detach, resolved on the live inventory; its current value replaces the references + * (V9: whether it is active or archived does not matter). + * + * @param array $input + */ + private function variableTarget(array $input): DetachTarget|WP_Error + { + $needle = is_string($input['variable'] ?? null) ? trim($input['variable']) : ''; + if ($needle === '') { + return self::invalid(['variable' => 'Name oder ID der Variablen erwartet']); + } + try { + $postIds = PostIdsInput::parse($input['post_ids'] ?? null); + } catch (InvalidArgumentException $e) { + return self::invalid(['post_ids' => $e->getMessage()]); + } + if (str_starts_with($needle, 'gcid-')) { + return Errors::unsupported( + sprintf('„%s“ ist eine globale Farbe; nichts wurde geändert.', $needle), + self::NO_COLORS + ); + } + + $book = VariableBook::fromDivi($this->variables->read()); + try { + $found = $book->find($needle); + } catch (InvalidArgumentException $e) { + return self::invalid(['variable' => $e->getMessage()]); + } + if ($found === null) { + return Errors::notFound( + sprintf('Unbekannte Variable „%s“; nichts wurde geändert.', $needle), + 'Vorhandene Variablen mit Namen und IDs liefert lit/divi-design-system (direkt: ' + . 'lit-divi-design-system). ' . self::NO_COLORS + ); + } + + $id = $found['id']; + $type = $found['type']; + $entry = $book->entry($type, $id) ?? []; + $label = is_string($entry['label'] ?? null) && $entry['label'] !== '' ? $entry['label'] : $id; + $value = $entry['value'] ?? null; + if (! is_string($value)) { + return Errors::unsupported( + sprintf('Variable „%s“ (Typ %s) hat keinen Textwert; nichts wurde geändert.', $label, $type), + 'Verweise auf diese Variable im Divi-Builder lösen.' + ); + } + $nested = $postIds === null && PresetBook::fromDivi($this->writer->presets())->referencing($id) !== []; + $affixes = $type === 'strings'; + + return new DetachTarget( + sprintf('Variable „%s“', $label), + $postIds, + new ReferenceUsageQuery([$id]), + static fn (): NodeRewrite => new VariableDetachRewrite($id, $value, $type), + $nested + ? static fn (PresetBook $presets): PresetBook => $presets->withVariableResolved($id, $value, $affixes) + : null, + 'Presets', + [], + self::ARCHIVE_HINT + ); + } + /** * The preset to detach, resolved on the live inventory. * diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index cea55e0..e2f090b 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -22,6 +22,7 @@ use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility; +use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; use LIT\MCP\Integrations\Divi\Design\DiviPresetsAssignAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility; @@ -274,6 +275,7 @@ final class DiviIntegration implements Integration, Bootable ), new DiviDesignDetachAbility( $this->designWriter, + new GlobalVariablesAdapter(), $this->designChange(), $this->globalUsages(), $this->pageRewriter($loader), diff --git a/tests/Integration/divi-presets-assign-smoke.sh b/tests/Integration/divi-presets-assign-smoke.sh index 53a101b..7ff5724 100755 --- a/tests/Integration/divi-presets-assign-smoke.sh +++ b/tests/Integration/divi-presets-assign-smoke.sh @@ -216,8 +216,8 @@ pass "mass assignment: dry run lists 2 pages, confirm saved 2 (remaining 0), sec # 6. detach of the group preset: it is in use, a stale design_rev is refused, then dry run, confirm, delete. GROUP_ITEM=$(jq -cn --arg g "$PRESET_G" '{items: [{group: "font", preset: $g}]}') [ "$(error_code "$(ability lit/divi-presets-delete "$GROUP_ITEM")")" = "in_use" ] || fail "delete of a used group preset" -UNSUPPORTED=$(ability lit/divi-design-detach '{"variable": "Irgendeine Variable"}') -[ "$(error_code "$UNSUPPORTED")" = "unsupported" ] || fail "detach of a variable: $UNSUPPORTED" +UNKNOWN_VARIABLE=$(ability lit/divi-design-detach '{"variable": "Irgendeine Variable"}') +[ "$(error_code "$UNKNOWN_VARIABLE")" = "not_found" ] || fail "detach of an unknown variable: $UNKNOWN_VARIABLE" DETACH=$(jq -cn --arg g "$PRESET_G" '{preset: {group: "font", preset: $g}}') DRY=$(timed "detach dry run" ability lit/divi-design-detach "$DETACH") [ "$(error_code "$DRY")" = "confirm_required" ] || fail "detach without confirm: $DRY" @@ -227,7 +227,7 @@ STALE=$(ability lit/divi-design-detach "$(echo "$DETACH" | jq -c '. + {confirm: [ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale design_rev: $STALE" nested_groups "$PRESET_B" | jq -e 'length > 0' >/dev/null || fail "stale detach changed preset B" button_attrs "$PAGE1" | jq -e '(.groupPreset // null) != null' >/dev/null || fail "stale detach changed page 1" -pass "group preset is in use, variable detach unsupported, stale design_rev refused without writing" +pass "group preset is in use, unknown variable not found, stale design_rev refused without writing" echo "info - preset B before detach: $(preset_item "$PRESET_B")" >&2 DETACHED=$(timed "detach confirmed" ability lit/divi-design-detach "$(echo "$DETACH" | jq -c --arg r "$DETACH_REV" '. + {confirm: true, design_rev: $r}')") diff --git a/tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php b/tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php index 34fdc4a..0cb23bd 100644 --- a/tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Design/DiviDesignDetachAbilityTest.php @@ -8,14 +8,18 @@ use Brain\Monkey\Functions; use Closure; use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\WriteFailed; +use LIT\MCP\Integrations\Divi\BlockTree\Block; use LIT\MCP\Integrations\Divi\Design\DesignChange; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Design\DetachRewrite; use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility; +use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter; use LIT\MCP\Integrations\Divi\Design\PageRewriter; use LIT\MCP\Integrations\Divi\Design\PresetBook; use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery; +use LIT\MCP\Integrations\Divi\Design\ReferenceUsageQuery; use LIT\MCP\Integrations\Divi\Design\SnapshotStore; +use LIT\MCP\Integrations\Divi\Design\VariableDetachRewrite; use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\UsageReport; use LIT\MCP\Integrations\Divi\Release\DiviFeature; @@ -33,6 +37,10 @@ final class DiviDesignDetachAbilityTest extends TestCase { private const LESETEXT = ['group' => 'font-body', 'preset' => 'Lesetext']; + private const RADIUS = 'gvid-radius0001'; + + private const CLAIM = 'gvid-claim00001'; + protected function setUp(): void { parent::setUp(); @@ -48,13 +56,15 @@ final class DiviDesignDetachAbilityTest extends TestCase ?SnapshotStore $snapshots = null, ?FakeChangeLog $log = null, ?UsageReport $report = null, - bool $unfiltered = true + 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, @@ -77,6 +87,64 @@ final class DiviDesignDetachAbilityTest extends TestCase return $writer; } + /** Reads variables() (the Variablen-Verwaltung is allowed unless $manage is false), never saves. */ + private static function variableAdapter(bool $manage = true): GlobalVariablesAdapter&MockInterface + { + $variables = Mockery::mock(GlobalVariablesAdapter::class); + $variables->allows('read')->andReturn(self::variables()); + $variables->allows('canManage')->andReturn($manage); + $variables->expects('save')->never(); + + return $variables; + } + + /** @return array>> Global variables in Divi's stored form. */ + private static function variables(): array + { + return [ + 'numbers' => [self::RADIUS => [ + 'id' => self::RADIUS, 'label' => 'Eckenradius', 'value' => '8px', '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); @@ -110,14 +178,158 @@ final class DiviDesignDetachAbilityTest extends TestCase self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'forbidden'); } - public function testVariablesFollowWithStage4c(): void + public function testAVariableNeedsTheVariableRightsToo(): void { - $error = self::assertToolError( - $this->ability(self::untouched())->execute(['variable' => 'Eckenradius']), - 'unsupported' - ); + $ability = $this->ability(self::untouched(), null, null, null, null, true, self::variableAdapter(false)); - self::assertStringContainsString('Variablen folgen mit Etappe 4c.', $error['hint']); + 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'] + ); + } + + 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 diff --git a/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php b/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php index 735aadc..b6ebd38 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php @@ -173,6 +173,13 @@ final class DiviToolFeaturesTest extends TestCase 'lit/divi-presets-set', ['items' => [1, ['from_node' => ['post_id' => $library, 'id' => 's0']]]], ['design-read', 'preset-write', 'catalog', 'page-read', 'roles'], ], + 'design-detach of a variable' => [ + 'lit/divi-design-detach', ['variable' => 'Eckenradius'], + [ + 'page-read', 'page-write', 'catalog', 'design-read', 'preset-write', 'library-read', + 'library-write', 'tb-write', 'roles', 'variable-write', + ], + ], 'library-list' => ['lit/library-list', [], ['library-read', 'roles']], 'library-list of one entry' => [ 'lit/library-list', ['id' => $library], ['library-read', 'roles', 'page-read', 'design-read'],