Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
781 lines
34 KiB
PHP
781 lines
34 KiB
PHP
<?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\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\Integrations\Divi\Release\ReleaseState;
|
||
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('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<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('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('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<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());
|
||
}
|
||
}
|