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

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

787 lines
34 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Compatibility\ReleaseState;
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;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\LockTable;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
use ReflectionProperty;
use RuntimeException;
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();
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<string, mixed>|null $stored
*/
private static function readOnlyWriter(?array $stored = null): DesignSystemWriter&MockInterface
{
$writer = Mockery::mock(DesignSystemWriter::class);
$writer->allows('refresh');
$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('refresh');
$variables->allows('read')->andReturn(self::variables($radius));
$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(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<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
{
$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('refresh');
$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<string, array{string, bool}> */
public static function unsafeValues(): array
{
return [
'empty' => ['', true],
'marker' => ['$variable({"type":"content"})$', true],
'end marker' => ['x)$', true],
'markup without unfiltered_html' => ['<b>8px</b>', 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, '<b>8px</b>'));
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('refresh');
$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('refresh');
$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('refresh');
$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<string, mixed> 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<string, array{RuntimeException, string}> */
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<string, array{UsageReport}> */
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());
}
}