feat: lit/divi-design-detach entkoppelt Variablen – Verweise in Seiten und Presets werden durch den Wert ersetzt (V9)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 14:14:49 +02:00
co-authored by Claude Sonnet 5.5
parent 6e9218a2bc
commit e951cc1353
5 changed files with 358 additions and 44 deletions
@@ -21,27 +21,32 @@ use WP_Error;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* lit/divi-design-detach (spec §6.8, ruling R10 – presets only): removes every explicit reference to one preset – * lit/divi-design-detach (spec §6.8, rulings R10, V9): removes every explicit reference to one preset or one global
* from the modulePreset/groupPreset stacks of pages, library entries and Theme Builder layouts (DetachRewrite through * variable from pages, library entries and Theme Builder layouts (DetachRewrite or VariableDetachRewrite through the
* the PageRewriter) and, for a group preset without post_ids, from the groupPresets of other presets (DesignChange, * PageRewriter) and, without post_ids, from presets that nest it (DesignChange, snapshot "presets"): a group preset
* snapshot "presets"). Modules fall back to the next preset of their stack or the default. The dry run is ONE * leaves the groupPresets of other presets, a variable reference in a preset becomes the variable's value. Modules
* confirm_required with the pages and, when other presets nest it, the diff and design_rev of the preset step. * fall back to the next preset of their stack or the default; a variable reference becomes the value (the variable
* Confirmed, the preset step runs first – its plan carries the page usages, so a blocked live post refuses before * itself stays as it is). The dry run is ONE confirm_required with the pages and, when presets nest the target, the
* anything is written (P8a) – then at most PageRewriter::PAGE_LIMIT pages. A default preset keeps working implicitly * diff and design_rev of the preset step. Confirmed, the preset step runs first – its plan carries the page usages,
* (Divi applies it); only explicit references go. Variables follow with Etappe 4c. * 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 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 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 ' 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.'; . '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). */ /** @param (Closure(): bool)|null $unfiltered Whether the user may save unfiltered HTML (DesignRights). */
public function __construct( public function __construct(
private DesignSystemWriter $writer, private DesignSystemWriter $writer,
private GlobalVariablesAdapter $variables,
private DesignChange $change, private DesignChange $change,
private GlobalUsages $usages, private GlobalUsages $usages,
private PageRewriter $pages, private PageRewriter $pages,
@@ -57,23 +62,26 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
public function label(): string public function label(): string
{ {
return 'Divi-Preset-Verweise entfernen'; return 'Divi-Preset- und Variablen-Verweise entfernen';
} }
public function description(): string public function description(): string
{ {
return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) aus ' return 'Entfernt alle Verweise auf ein Preset (preset: module bzw. group + preset als Name oder ID) oder auf '
. 'Seiten, Bibliothekseinträgen und Theme-Builder-Layouts und – bei Gruppen-Presets – aus anderen Presets; ' . 'eine globale Variable (variable: Name oder ID) aus Seiten, Bibliothekseinträgen und '
. 'Module fallen auf das nächste Preset im Stapel bzw. das Standard-Preset zurück. post_ids beschränkt auf ' . 'Theme-Builder-Layouts und – bei Gruppen-Presets und Variablen – aus Presets; Module fallen auf das '
. 'diese Seiten (Verweise in Presets bleiben dann). Zuerst Probelauf (pages; bei Verweisen in Presets diff ' . 'nächste Preset im Stapel bzw. das Standard-Preset zurück, ein Variablenverweis wird durch den aktuellen '
. 'und design_rev), nach Rückfrage beim Nutzer confirm: true (mit design_rev, wenn der Probelauf eine ' . 'Wert der Variablen ersetzt (die Variable selbst bleibt; Verweise mitten in einem längeren Text bleiben '
. 'nennt); je Aufruf höchstens ' . PageRewriter::PAGE_LIMIT . ' Seiten, remaining nennt den Rest. Mit ' . 'stehen und stehen als Hinweis in warnings). post_ids beschränkt auf diese Seiten (Verweise in Presets '
. 'post_ids können nur die genannten Seiten das Schreiben blockieren und die Obergrenze für sehr viele ' . 'bleiben dann). Zuerst Probelauf (pages; bei Verweisen in Presets diff und design_rev), nach Rückfrage '
. 'Seiten gilt nicht; eine genannte veröffentlichte, private oder geplante Seite ohne Bearbeitungsrecht ' . 'beim Nutzer confirm: true (mit design_rev, wenn der Probelauf eine nennt); je Aufruf höchstens '
. 'verweigert den ganzen Aufruf (forbidden, blocked_ids), eine genannte andere Seite ohne ' . PageRewriter::PAGE_LIMIT . ' Seiten, remaining nennt den Rest. Mit post_ids können nur die genannten '
. 'Bearbeitungsrecht steht in skipped (bei mehr als 2000 Live-Fundstellen entfällt die Vorabprüfung, ' . 'Seiten das Schreiben blockieren und die Obergrenze für sehr viele Seiten gilt nicht; eine genannte '
. 'dann auch eine genannte Live-Seite). Danach kann lit/divi-presets-delete das Preset löschen. ' . 'veröffentlichte, private oder geplante Seite ohne Bearbeitungsrecht verweigert den ganzen Aufruf '
. self::VARIABLES; . '(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 public function inputSchema(): array
@@ -90,7 +98,10 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
], ],
'required' => ['preset'], '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(), 'post_ids' => PostIdsInput::schema(),
] + DesignChange::inputProperties(), ] + DesignChange::inputProperties(),
'additionalProperties' => false, '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 * 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 public function diviFeatures(array $input): array
{ {
return [ $features = [
DiviFeature::PageRead, DiviFeature::PageRead,
DiviFeature::PageWrite, DiviFeature::PageWrite,
DiviFeature::Catalog, DiviFeature::Catalog,
@@ -132,26 +144,25 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
DiviFeature::ThemeBuilderWrite, DiviFeature::ThemeBuilderWrite,
DiviFeature::Roles, DiviFeature::Roles,
]; ];
return array_key_exists('variable', $input) ? [...$features, DiviFeature::VariableWrite] : $features;
} }
public function permission(array $input): bool|WP_Error 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 public function execute(array $input): mixed
{ {
$refusal = DesignRights::presets($this->writer); $refusal = $this->rights($input);
if ($refusal !== null) { if ($refusal !== null) {
return $refusal; return $refusal;
} }
if (array_key_exists('variable', $input)) { if (array_key_exists('variable', $input) && array_key_exists('preset', $input)) {
return Errors::unsupported( return self::invalid(['variable' => 'entweder preset oder variable angeben, nicht beides']);
'Verweise auf Variablen entfernt LIT MCP noch nicht; nichts wurde geändert.',
self::VARIABLES . ' Bis dahin nur Presets angeben (preset).'
);
} }
$target = $this->presetTarget($input); $target = array_key_exists('variable', $input) ? $this->variableTarget($input) : $this->presetTarget($input);
if ($target instanceof WP_Error) { if ($target instanceof WP_Error) {
return $target; return $target;
} }
@@ -159,6 +170,88 @@ final class DiviDesignDetachAbility implements Ability, RequiresDiviFeatures
return ($input['confirm'] ?? false) === true ? $this->confirmed($input, $target) : $this->dryRun($target); 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<string, mixed> $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<string, mixed> $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. * The preset to detach, resolved on the live inventory.
* *
@@ -22,6 +22,7 @@ use LIT\MCP\Integrations\Divi\Design\DiviPresetGetAbility;
use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignRestoreAbility;
use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility; use LIT\MCP\Integrations\Divi\Design\DiviDesignSnapshotsAbility;
use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility; 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\DiviPresetsAssignAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetsDeleteAbility;
use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility; use LIT\MCP\Integrations\Divi\Design\DiviPresetsSetAbility;
@@ -274,6 +275,7 @@ final class DiviIntegration implements Integration, Bootable
), ),
new DiviDesignDetachAbility( new DiviDesignDetachAbility(
$this->designWriter, $this->designWriter,
new GlobalVariablesAdapter(),
$this->designChange(), $this->designChange(),
$this->globalUsages(), $this->globalUsages(),
$this->pageRewriter($loader), $this->pageRewriter($loader),
@@ -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. # 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}]}') 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" [ "$(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"}') UNKNOWN_VARIABLE=$(ability lit/divi-design-detach '{"variable": "Irgendeine Variable"}')
[ "$(error_code "$UNSUPPORTED")" = "unsupported" ] || fail "detach of a variable: $UNSUPPORTED" [ "$(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}}') DETACH=$(jq -cn --arg g "$PRESET_G" '{preset: {group: "font", preset: $g}}')
DRY=$(timed "detach dry run" ability lit/divi-design-detach "$DETACH") DRY=$(timed "detach dry run" ability lit/divi-design-detach "$DETACH")
[ "$(error_code "$DRY")" = "confirm_required" ] || fail "detach without confirm: $DRY" [ "$(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" [ "$(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" 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" 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 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}')") DETACHED=$(timed "detach confirmed" ability lit/divi-design-detach "$(echo "$DETACH" | jq -c --arg r "$DETACH_REV" '. + {confirm: true, design_rev: $r}')")
@@ -8,14 +8,18 @@ use Brain\Monkey\Functions;
use Closure; use Closure;
use LIT\MCP\Abilities\Errors; use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\WriteFailed; 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\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter; use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DetachRewrite; use LIT\MCP\Integrations\Divi\Design\DetachRewrite;
use LIT\MCP\Integrations\Divi\Design\DiviDesignDetachAbility; 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\PageRewriter;
use LIT\MCP\Integrations\Divi\Design\PresetBook; use LIT\MCP\Integrations\Divi\Design\PresetBook;
use LIT\MCP\Integrations\Divi\Design\PresetUsageQuery; 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\SnapshotStore;
use LIT\MCP\Integrations\Divi\Design\VariableDetachRewrite;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages; use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\UsageReport; use LIT\MCP\Integrations\Divi\Library\UsageReport;
use LIT\MCP\Integrations\Divi\Release\DiviFeature; 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 LESETEXT = ['group' => 'font-body', 'preset' => 'Lesetext'];
private const RADIUS = 'gvid-radius0001';
private const CLAIM = 'gvid-claim00001';
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
@@ -48,13 +56,15 @@ final class DiviDesignDetachAbilityTest extends TestCase
?SnapshotStore $snapshots = null, ?SnapshotStore $snapshots = null,
?FakeChangeLog $log = null, ?FakeChangeLog $log = null,
?UsageReport $report = null, ?UsageReport $report = null,
bool $unfiltered = true bool $unfiltered = true,
?GlobalVariablesAdapter $variables = null
): DiviDesignDetachAbility { ): DiviDesignDetachAbility {
$usages = Mockery::mock(GlobalUsages::class); $usages = Mockery::mock(GlobalUsages::class);
$usages->allows('reportFor')->andReturn($report ?? new UsageReport(0, [], 0, 0, [], 0, false)); $usages->allows('reportFor')->andReturn($report ?? new UsageReport(0, [], 0, 0, [], 0, false));
return new DiviDesignDetachAbility( return new DiviDesignDetachAbility(
$writer ?? self::readOnlyWriter(), $writer ?? self::readOnlyWriter(),
$variables ?? self::variableAdapter(),
new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), $log ?? FakeChangeLog::forbidden()), new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), $log ?? FakeChangeLog::forbidden()),
$usages, $usages,
$pages, $pages,
@@ -77,6 +87,64 @@ final class DiviDesignDetachAbilityTest extends TestCase
return $writer; 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<string, array<string, array<string, mixed>>> 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<string, mixed> 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(
'<!-- wp:divi/text {"x":"' . $id . '"} /-->'
) && ! $query->matches('<!-- wp:divi/text {"x":"gvid-andere0001"} /-->');
}
/** 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 private static function untouched(): PageRewriter&MockInterface
{ {
$pages = Mockery::mock(PageRewriter::class); $pages = Mockery::mock(PageRewriter::class);
@@ -110,14 +178,158 @@ final class DiviDesignDetachAbilityTest extends TestCase
self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'forbidden'); self::assertToolError($ability->execute(['preset' => self::LESETEXT]), 'forbidden');
} }
public function testVariablesFollowWithStage4c(): void public function testAVariableNeedsTheVariableRightsToo(): void
{ {
$error = self::assertToolError( $ability = $this->ability(self::untouched(), null, null, null, null, true, self::variableAdapter(false));
$this->ability(self::untouched())->execute(['variable' => 'Eckenradius']),
'unsupported'
);
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 public function testUnknownAndAmbiguousPresets(): void
@@ -173,6 +173,13 @@ final class DiviToolFeaturesTest extends TestCase
'lit/divi-presets-set', ['items' => [1, ['from_node' => ['post_id' => $library, 'id' => 's0']]]], 'lit/divi-presets-set', ['items' => [1, ['from_node' => ['post_id' => $library, 'id' => 's0']]]],
['design-read', 'preset-write', 'catalog', 'page-read', 'roles'], ['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' => ['lit/library-list', [], ['library-read', 'roles']],
'library-list of one entry' => [ 'library-list of one entry' => [
'lit/library-list', ['id' => $library], ['library-read', 'roles', 'page-read', 'design-read'], 'lit/library-list', ['id' => $library], ['library-read', 'roles', 'page-read', 'design-read'],