fix: identical means identical bytes – text parts count, pages and posts only (ruling Q25)

- Fingerprint::content() includes non-whitespace text parts (raw HTML between
  blocks, inner HTML of core blocks) and opaque blocks with all their bytes;
  structure() unchanged; find-similar "identical" follows
- make-global mode 2 also compares Fingerprint::canonical() bytes of each node
- sources and targets: post types page and post only (POST_TYPES)
- a rolled-back page N>1 keeps DiviSaver's "Revision X bleibt erhalten"
- guide: escaped divi/global-layout re-expanded by Divi's do_blocks in
  shortcode-module and WooCommerce text output documented as residual risk

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-27 21:59:20 +02:00
co-authored by Claude Opus 5.5
parent e733065b60
commit 4e959833b9
6 changed files with 238 additions and 21 deletions
@@ -490,4 +490,78 @@ final class LibraryMakeGlobalAbilityTest extends TestCase
}
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);
}
}
}