Files
WP-LIT-MCP/tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php
T
janniclas.loosenandClaude Opus 5.5 a8afa37208 fix: make-global keeps the backup sentence on a failure on page 2+ (ruling P14)
failedOn() replaced the hint for a failure after earlier pages were
replaced, so the "Sicherung X liegt im Papierkorb" sentence of the failed
page's backup was lost. The hint now leads with it when a backup exists.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-27 23:25:53 +02:00

638 lines
28 KiB
PHP
Raw 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\Library;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Integrations\Divi\Library\LibraryMakeGlobalAbility;
use LIT\MCP\Integrations\Divi\Library\NewLibraryEntry;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
use LIT\MCP\Tests\Unit\Integrations\Divi\Pages\VisibleEditsTest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Throwable;
use WP_Post;
final class LibraryMakeGlobalAbilityTest extends TestCase
{
private const BACKUP_HINT = 'Revisionen aktivieren (WP_POST_REVISIONS) oder die Seite im Divi-Builder bearbeiten.';
private WP_Post $page;
/** @var list<LibraryItemSpec> */
private array $specs = [];
/** @var list<array{int, string, SaveScope}> */
private array $entryContents = [];
/** @var list<string> Page contents DiviWriter saved. */
private array $saved = [];
private FakeChangeLog $log;
/** @var list<array{post_id: int, backup_id: int}> Backups VisibleEdits made. */
private array $backups = [];
protected function setUp(): void
{
parent::setUp();
$this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
LibraryFixtures::given([$this->page]);
Functions\when('wp_revisions_enabled')->justReturn(true);
$this->log = DiviWriterFixture::log();
}
/**
* @param list<string> $deniedRoles
* @param list<Throwable> $pageFailures
*/
private function ability(
array $deniedRoles = [],
bool $diviWritable = true,
bool $unfiltered = false,
array $pageFailures = [],
?DiviPageLoader $loader = null,
?DiviWriter $writer = null
): LibraryMakeGlobalAbility {
$store = LibraryFixtures::store();
$store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int {
$this->specs[] = $spec;
return 2300;
});
$saver = Mockery::mock(DiviSaver::class);
$saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope): array {
$this->entryContents[] = [$id, $content, $scope];
return [];
});
$access = LibraryFixtures::access($deniedRoles, true, $store);
return new LibraryMakeGlobalAbility(
$access,
$writer ?? DiviWriterFixture::writer(
$this->saved,
$pageFailures,
$unfiltered,
$loader ?? LibraryFixtures::loader($access)
),
new NewLibraryEntry($access, $saver, DiviWriterFixture::modules()),
$this->log,
static fn (): bool => $diviWritable,
new VisibleEdits(function (WP_Post $post): int {
$this->backups[] = ['post_id' => (int) $post->ID, 'backup_id' => 1071];
return 1071;
})
);
}
/** @return array<string, mixed> */
private function input(array $extra = []): array
{
return array_merge(
['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'],
$extra
);
}
public function testReplacesTheNodeByAnInstanceOfTheNewGlobalEntry(): void
{
$result = $this->ability()->execute($this->input());
self::assertTrue($result['ok']);
self::assertSame(2300, $result['library_id']);
self::assertTrue($this->specs[0]->global);
self::assertSame(
str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')),
$this->entryContents[0][1],
'the entry holds exactly the node, framed like the builder'
);
self::assertCount(1, $this->saved);
self::assertStringContainsString(
'<!-- wp:divi/global-layout {"globalModule":"2300","blockName":"divi/section","localAttrs":',
$this->saved[0]
);
self::assertStringNotContainsString(
'<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Abschnitt"',
$this->saved[0],
'the plain section is gone'
);
self::assertNotSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, 'lit/library-find-similar')));
}
public function testEditorCannotMakeACodeModuleGlobal(): void
{
$this->page = LibraryFixtures::page(
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
. '<!-- wp:divi/code {"content":{"innerContent":{"desktop":{"value":"<b>x</b>"}}}} /-->'
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
);
LibraryFixtures::given([$this->page]);
$result = $this->ability()->execute($this->input(['id' => 's0']));
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('Code-Modul', implode(' ', self::errorFields($result)));
self::assertSame([], $this->specs);
self::assertSame([], $this->saved);
}
/** All or nothing: the page save failing removes the new entry again. */
public function testFailedPageSaveDeletesTheNewEntry(): void
{
Functions\expect('wp_delete_post')->once()->with(2300, true)->andReturn(new WP_Post(['ID' => 2300]));
try {
$this->ability([], true, false, [WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
->execute($this->input());
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertTrue($e->rolledBack);
}
self::assertCount(1, $this->specs);
}
public function testRefusesNodesThatAreOrHoldGlobalModules(): void
{
self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed');
self::assertToolError($this->ability()->execute($this->input(['id' => 's0'])), 'validation_failed');
self::assertSame([], $this->specs);
}
public function testNeedsSaveGlobalAndInsertRightsAndTheDiviGate(): void
{
self::assertToolError($this->ability(['edit_global_library'])->execute($this->input()), 'forbidden');
self::assertToolError($this->ability(['add_library'])->execute($this->input()), 'forbidden');
self::assertToolError($this->ability([], false)->execute($this->input()), 'unsupported');
self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'alt'])), 'rev_mismatch');
self::assertSame([], $this->specs);
self::assertFalse($this->ability()->isDirect());
}
public function testLogsTheNewEntry(): void
{
$this->ability()->execute($this->input());
self::assertSame('et_pb_layout', $this->log->records[0]['object_type']);
self::assertSame('2300', $this->log->records[0]['object_id']);
self::assertStringContainsString('global', $this->log->records[0]['summary']);
}
/**
* Ruling Q7: a Divi 4 shortcode in a text value renders another entry (or, casting to 0, the newest
* layout – the entry being created); such a node never becomes a global entry.
*/
public function testRefusesAShortcodeReferenceInsideAValue(): void
{
$quote = chr(92) . '"';
foreach (['1058', 'abc'] as $ref) {
$this->page = LibraryFixtures::page(
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
. '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"[et_pb_text global_module='
. $quote . $ref . $quote . ']x[/et_pb_text]"}}}} /-->'
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
);
LibraryFixtures::given([$this->page]);
$result = $this->ability([], true, true)->execute($this->input(['id' => 's0']));
self::assertToolError($result, 'validation_failed');
self::assertStringContainsString('global_module', implode(' ', self::errorFields($result)));
}
self::assertSame([], $this->specs);
self::assertSame([], $this->saved);
}
public function testLibraryEntriesAndThemeBuilderLayoutsAreNoSource(): void
{
$item = LibraryFixtures::item(1055);
LibraryFixtures::given([$item]);
$fromLibrary = $this->ability()->execute(
['post_id' => 1055, 'tree_rev' => Rev::of((string) $item->post_content), 'id' => 's0', 'title' => 'x']
);
self::assertToolError($fromLibrary, 'validation_failed');
self::assertArrayHasKey('post_id', self::errorFields($fromLibrary));
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
LibraryFixtures::given([$body]);
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
$loader = new DiviPageLoader(
DiviFixtures::schema(),
new Parser(),
LibraryFixtures::access(),
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template()]))
);
$fromLayout = $this->ability([], true, false, [], $loader)->execute(
['post_id' => 1062, 'tree_rev' => Rev::of((string) $body->post_content), 'id' => 's0', 'title' => 'x']
);
self::assertToolError($fromLayout, 'validation_failed');
self::assertStringContainsString('Theme-Builder', $fromLayout->get_error_message());
self::assertSame([], $this->specs);
self::assertSame([], $this->saved);
}
/** A page write that may have been saved keeps the entry – the page may already show it. */
public function testAPartialPageWriteKeepsTheEntry(): void
{
Functions\expect('wp_delete_post')->never();
$failures = [WriteFailed::partial('Die Divi-Seite konnte nicht gespeichert werden.', postId: 7)];
$design = DiviWriterFixture::designReader();
$writer = new DiviWriter(
LibraryFixtures::loader(),
DiviWriterFixture::saver($this->saved, $failures),
FakeChangeLog::any(),
$design,
DiviWriterFixture::composition($design)
);
try {
$this->ability([], true, false, [], null, $writer)->execute($this->input());
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertFalse($e->rolledBack);
self::assertSame(2300, $e->details['library_id']);
}
}
/** Pages 8 and 9: a plain copy of the global section 1058; 9 also a variation with another text. */
private function givenCopiesOf1058(): string
{
$root = LibraryContent::roots(LibraryContent::load(
new Parser(),
DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html')
))[0];
$copy = Serializer::block($root);
$variation = str_replace('Global', 'Lokal', $copy);
LibraryFixtures::given([
$this->page,
LibraryFixtures::item(1058),
new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Team',
'post_content' => '<!-- wp:divi/placeholder -->' . $copy . '<!-- /wp:divi/placeholder -->']),
new WP_Post(['ID' => 9, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Jobs',
'post_content' => '<!-- wp:divi/placeholder -->' . $variation . $copy . '<!-- /wp:divi/placeholder -->']),
]);
return $copy;
}
/** @return array<string, mixed> */
private static function targets(array $ids8 = ['s0'], array $ids9 = ['s1'], bool $confirm = false): array
{
return array_filter([
'library_id' => 1058,
'targets' => [
['post_id' => 8, 'tree_rev' => Rev::of((string) get_post(8)->post_content), 'ids' => $ids8],
['post_id' => 9, 'tree_rev' => Rev::of((string) get_post(9)->post_content), 'ids' => $ids9],
],
'confirm' => $confirm ?: null,
], static fn (mixed $value): bool => $value !== null);
}
public function testReplacingOccurrencesIsADryRunFirstThenEveryPageWithItsRevision(): void
{
$this->givenCopiesOf1058();
$dry = $this->ability()->execute(self::targets());
self::assertToolError($dry, 'confirm_required');
self::assertSame(2, $dry->get_error_data()['affected_count']);
self::assertSame([8, 9], array_column($dry->get_error_data()['affected'], 'post_id'));
self::assertSame([], $this->saved);
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
self::assertTrue($result['ok']);
self::assertSame([8, 9], array_column($result['pages'], 'post_id'));
self::assertSame([811, 811], array_column($result['pages'], 'revision_id'));
self::assertCount(2, $this->saved);
self::assertStringContainsString('{"globalModule":"1058","blockName":"divi/section"', $this->saved[0]);
self::assertStringContainsString('Lokal', $this->saved[1], 'the variation stays');
self::assertSame([], $this->specs, 'no new entry');
}
public function testTargetsMustBeIdenticalEditableAndCurrent(): void
{
$this->givenCopiesOf1058();
$variation = $this->ability()->execute(self::targets(['s0'], ['s0'], true));
self::assertToolError($variation, 'validation_failed');
self::assertArrayHasKey('targets.1.ids.0', self::errorFields($variation));
$stale = self::targets(['s0'], ['s1'], true);
$stale['targets'][1]['tree_rev'] = 'alt';
self::assertToolError($this->ability()->execute($stale), 'rev_mismatch');
$library = self::targets(['s0'], ['s1'], true);
$library['targets'][1] = ['post_id' => 1055, 'tree_rev' => Rev::of((string) LibraryFixtures::item(1055)->post_content), 'ids' => ['s0']];
LibraryFixtures::given([LibraryFixtures::item(1055), LibraryFixtures::item(1058), get_post(8), get_post(9)]);
self::assertToolError($this->ability()->execute($library), 'validation_failed');
LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), get_post(9)]);
Functions\when('current_user_can')->alias(static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 9));
self::assertToolError($this->ability()->execute(self::targets(['s0'], ['s1'], true)), 'forbidden');
self::assertSame([], $this->saved, 'nothing was written in any case');
}
public function testOnlyGlobalEntriesWithOneReadableRoot(): void
{
$this->givenCopiesOf1058();
LibraryFixtures::given([LibraryFixtures::item(1055), get_post(8), get_post(9)]);
$normal = self::targets();
$normal['library_id'] = 1055;
self::assertToolError($this->ability()->execute($normal), 'validation_failed');
}
public function testAFailureMidwayNamesThePagesAlreadyChanged(): void
{
$this->givenCopiesOf1058();
try {
$this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
->execute(self::targets(['s0'], ['s1'], true));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertSame([8], $e->details['changed_posts']);
}
}
/** The dry run lists every target with its tree_rev and whether visitors see it (P8a: drafts only listed). */
public function testTheDryRunListsTreeRevAndLiveState(): void
{
$this->givenCopiesOf1058();
$data = $this->ability()->execute(self::targets())->get_error_data();
self::assertSame(Rev::of((string) get_post(8)->post_content), $data['affected'][0]['tree_rev']);
self::assertTrue($data['affected'][0]['live']);
self::assertFalse($data['affected'][1]['live']);
self::assertSame(['s1'], $data['affected'][1]['ids']);
self::assertSame(1, $data['live_count']);
}
/** Ruling Q7: an entry that names another entry in any spelling never becomes an instance. */
public function testRefusesAnEntryWithAReferenceInAnySpelling(): void
{
$copy = $this->givenCopiesOf1058();
$quote = chr(92) . '"';
$content = str_replace(
'Globaler Abschnitt',
'[et_pb_text global_module=' . $quote . '1059' . $quote . ']x[/et_pb_text]',
DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html')
);
LibraryFixtures::given([LibraryFixtures::item(1058, ['post_content' => $content]), get_post(8), get_post(9)]);
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
self::assertToolError($result, 'validation_failed');
self::assertArrayHasKey('library_id', self::errorFields($result));
self::assertSame([], $this->saved);
self::assertNotSame('', $copy);
}
public function testThemeBuilderLayoutsAreNoTargets(): void
{
$this->givenCopiesOf1058();
$input = self::targets(['s0'], ['s1'], true);
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), $body]);
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
$loader = new DiviPageLoader(
DiviFixtures::schema(),
new Parser(),
LibraryFixtures::access(),
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template()]))
);
$input['targets'][1] = ['post_id' => 1062, 'tree_rev' => Rev::of((string) $body->post_content), 'ids' => ['s0']];
$result = $this->ability([], true, false, [], $loader)->execute($input);
self::assertToolError($result, 'validation_failed');
self::assertArrayHasKey('targets.1.post_id', self::errorFields($result));
self::assertSame([], $this->saved);
}
/** Replacing needs lit-library-insert's rights (not lit/library-save's) and a writable library group. */
public function testReplacingNeedsInsertRightsOnly(): void
{
$this->givenCopiesOf1058();
self::assertToolError($this->ability(['add_library'])->execute(self::targets([], [], true)), 'forbidden');
self::assertToolError($this->ability([], false)->execute(self::targets()), 'unsupported');
self::assertToolError($this->ability(['edit_global_library'])->execute(self::targets()), 'confirm_required');
self::assertSame([], $this->saved);
}
/** Live pages without revisions get VisibleEdits' backup – refused before any page is written where it would lose bytes. */
public function testLivePagesWithoutRevisionsGetABackupOrNothingIsWritten(): void
{
$this->givenCopiesOf1058();
Functions\when('wp_revisions_enabled')->justReturn(false);
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
self::assertSame([['post_id' => 8, 'backup_id' => 1071]], $this->backups);
self::assertSame(1071, $result['pages'][0]['backup_id']);
self::assertArrayNotHasKey('backup_id', $result['pages'][1], 'drafts are only listed (P8a)');
$this->backups = [];
$this->saved = [];
$this->givenCopiesOf1058();
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html');
$refused = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
self::assertToolError($refused, 'unsupported');
self::assertSame(sprintf(VisibleEditsTest::UNFAITHFUL, 'Seite 8'), Errors::clientMessage($refused));
self::assertSame(self::BACKUP_HINT, $refused->get_error_data()['hint']);
self::assertSame([], $this->backups);
self::assertSame([], $this->saved);
}
/** Review I-3 (ruling Q17 M3): the trash refusal and a failure after the backup name the page. */
public function testTheBackupWordingNamesThePage(): void
{
$this->givenCopiesOf1058();
Functions\when('wp_revisions_enabled')->justReturn(false);
$noFailures = [];
$ability = new LibraryMakeGlobalAbility(
LibraryFixtures::access(),
DiviWriterFixture::writer($this->saved, $noFailures, false, LibraryFixtures::loader()),
new NewLibraryEntry(LibraryFixtures::access(), Mockery::mock(DiviSaver::class), DiviWriterFixture::modules()),
$this->log,
null,
new VisibleEdits(static fn (WP_Post $post): int => 1071, static fn (): bool => false)
);
$noTrash = $ability->execute(self::targets(['s0'], ['s1'], true));
self::assertToolError($noTrash, 'unsupported');
self::assertSame(sprintf(VisibleEditsTest::NO_TRASH, 'Seite 8'), Errors::clientMessage($noTrash));
self::assertSame(self::BACKUP_HINT, $noTrash->get_error_data()['hint']);
self::assertSame([], $this->saved);
$this->givenCopiesOf1058();
try {
$this->ability([], true, false, [WriteFailed::rolledBack('Grund.')])
->execute(self::targets(['s0'], ['s1'], true));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertSame(1071, $e->details['backup_id']);
self::assertSame(VisibleEditsTest::keptRolledBack(1071, 'der Seite'), $e->toError()->get_error_data()['hint']);
self::assertStringContainsString(
Errors::notSavedKeeping('Sicherung 1071 im Papierkorb'),
Errors::clientMessage($e->toError())
);
}
}
/** Re-review: a failure on page 2+ after its backup keeps the backup sentence in hint and message (P14). */
public function testAFailureMidwayAfterABackupKeepsTheBackupSentence(): void
{
$this->givenCopiesOf1058();
$jobs = get_post(9);
$jobs->post_status = 'publish';
LibraryFixtures::given([$this->page, LibraryFixtures::item(1058), get_post(8), $jobs]);
Functions\when('wp_revisions_enabled')->justReturn(false);
try {
$this->ability([], true, false, [null, WriteFailed::rolledBack('Grund.')])
->execute(self::targets(['s0'], ['s1'], true));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
$error = $e->toError();
self::assertSame(9, $e->details['failed_post']);
self::assertSame([8], $e->details['changed_posts']);
self::assertSame(1071, $e->details['backup_id']);
self::assertStringContainsString(
sprintf(VisibleEditsTest::KEPT, 1071, 'der Seite'),
$error->get_error_data()['hint']
);
self::assertStringContainsString('lit/library-find-similar', $error->get_error_data()['hint']);
self::assertStringContainsString('Sicherung 1071 im Papierkorb', Errors::clientMessage($error));
}
self::assertCount(2, $this->backups);
}
/** A failure on page N: the pages before stay replaced (each with revision), the rest untouched – all named. */
public function testAFailureMidwayReportsEveryPage(): void
{
$this->givenCopiesOf1058();
try {
$this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
->execute(self::targets(['s0'], ['s1'], true));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertSame(9, $e->details['failed_post']);
self::assertSame([], $e->details['unchanged_posts']);
self::assertSame(811, $e->details['pages'][0]['revision_id']);
self::assertStringNotContainsString('Nichts wurde gespeichert', (string) $e->toError()->get_error_message());
}
self::assertCount(1, $this->saved);
}
public function testAFailureOnTheFirstPageLeavesEverythingUnchanged(): void
{
$this->givenCopiesOf1058();
try {
$this->ability([], true, false, [WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
->execute(self::targets(['s0'], ['s1'], true));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
self::assertSame([], $e->details['changed_posts']);
self::assertSame([9], $e->details['unchanged_posts']);
}
self::assertSame([], $this->saved);
}
/** @return array<string, array{string}> */
public static function visibleExtras(): array
{
return [
'core paragraph' => ['<!-- wp:paragraph --><p>ONLY ON 8</p><!-- /wp:paragraph -->'],
'raw text part' => ['<p>ONLY ON 8</p>'],
];
}
/**
* Ruling Q25 (review of Task 15): a copy with extra visible text – a core paragraph or raw HTML between
* blocks – is no identical occurrence; replacing it would swap visible content.
*
* @dataProvider visibleExtras
*/
public function testACopyWithExtraVisibleTextIsNoIdenticalOccurrence(string $extra): void
{
$copy = $this->givenCopiesOf1058();
$at = strrpos($copy, '<!-- /wp:divi/column -->');
$changed = substr($copy, 0, (int) $at) . $extra . substr($copy, (int) $at);
LibraryFixtures::given([
LibraryFixtures::item(1058),
new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Team',
'post_content' => '<!-- wp:divi/placeholder -->' . $changed . '<!-- /wp:divi/placeholder -->']),
get_post(9),
]);
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
self::assertToolError($result, 'validation_failed');
self::assertArrayHasKey('targets.0.ids.0', self::errorFields($result));
self::assertSame([], $this->saved);
}
/** Scope (ruling Q25): only pages and posts – other Divi-enabled types are neither targets nor sources. */
public function testOnlyPagesAndPostsAreTargetsAndSources(): void
{
$this->givenCopiesOf1058();
$project = get_post(8);
$project->post_type = 'project';
LibraryFixtures::given([LibraryFixtures::item(1058), $project, get_post(9)]);
$target = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
self::assertToolError($target, 'validation_failed');
self::assertArrayHasKey('targets.0.post_id', self::errorFields($target));
$source = $this->ability()->execute(
['post_id' => 8, 'tree_rev' => Rev::of((string) $project->post_content), 'id' => 's0', 'title' => 'x']
);
self::assertToolError($source, 'validation_failed');
self::assertArrayHasKey('post_id', self::errorFields($source));
self::assertSame([], $this->saved);
self::assertSame([], $this->specs);
}
/** A rolled-back page N>1 keeps DiviSaver's note on the revision that stays. */
public function testAFailureMidwayKeepsTheRevisionNote(): void
{
$this->givenCopiesOf1058();
$kept = WriteFailed::rolledBack(
'Die Divi-Seite konnte nicht gespeichert werden.',
state: \LIT\MCP\Abilities\Errors::notSavedKeeping('Revision 812')
);
try {
$this->ability([], true, false, [null, $kept])->execute(self::targets(['s0'], ['s1'], true));
self::fail('no WriteFailed');
} catch (WriteFailed $e) {
$message = (string) $e->toError()->get_error_message();
self::assertStringContainsString('Revision 812 bleibt erhalten', $message);
self::assertStringContainsString('Bereits ersetzt', $message);
}
}
}