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
@@ -68,4 +68,68 @@ final class FingerprintTest extends TestCase
self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($b));
self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($c));
}
/** The section of 1055 with $extra inserted at the end of its column (review of Task 15, ruling Q25). */
private static function sectionWith(string $extra): Block
{
$markup = DiviFixtures::markup('stage3/library--library-abschnitt.html');
$at = strrpos($markup, '<!-- /wp:divi/column -->');
$markup = substr($markup, 0, (int) $at) . $extra . substr($markup, (int) $at);
return LibraryContent::roots(LibraryContent::load(new Parser(), $markup))[0];
}
/** @return array<string, array{string, string}> */
public static function visibleExtras(): array
{
return [
'raw html between blocks' => ['<p>ONLY ON B</p>', '<p>ONLY ON C</p>'],
'core paragraph' => [
'<!-- wp:paragraph --><p>ONLY ON B</p><!-- /wp:paragraph -->',
'<!-- wp:paragraph --><p>ONLY ON C</p><!-- /wp:paragraph -->',
],
'divi 4 shortcode text' => ['[et_pb_text]ONLY ON B[/et_pb_text]', '[et_pb_text]ONLY ON C[/et_pb_text]'],
];
}
/**
* Ruling Q25: text parts are visible – raw HTML between blocks and the inner HTML of core blocks count
* for the content, like attributes.
*
* @dataProvider visibleExtras
*/
public function testVisibleTextPartsCountForTheContent(string $extra, string $other): void
{
$plain = self::section();
self::assertNotSame(Fingerprint::content($plain), Fingerprint::content(self::sectionWith($extra)));
self::assertNotSame(
Fingerprint::content(self::sectionWith($extra)),
Fingerprint::content(self::sectionWith($other))
);
self::assertNotSame(Fingerprint::canonical($plain), Fingerprint::canonical(self::sectionWith($extra)));
}
public function testRawTextKeepsTheStructureAndWhitespaceNeverCounts(): void
{
$plain = self::section();
self::assertSame(Fingerprint::structure($plain), Fingerprint::structure(self::sectionWith('<p>x</p>')));
self::assertSame(Fingerprint::content($plain), Fingerprint::content(self::sectionWith("\n \n")));
self::assertSame(Fingerprint::canonical($plain), Fingerprint::canonical(self::sectionWith("\n\t")));
}
/** Opaque blocks count with all their bytes (a global instance's snapshot included). */
public function testOpaqueBlocksCountWithAllTheirBytes(): void
{
$instance = static fn (string $text): Block => LibraryContent::roots(LibraryContent::load(
new Parser(),
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
. '<!-- wp:divi/global-layout {"globalModule":"5","blockName":"divi/text"} --><p>' . $text
. '</p><!-- /wp:divi/global-layout -->'
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
))[0];
self::assertNotSame(Fingerprint::content($instance('a')), Fingerprint::content($instance('b')));
}
}
@@ -212,4 +212,33 @@ final class LibraryFindSimilarAbilityTest extends TestCase
self::assertCount(5, $rest['matches']);
self::assertArrayNotHasKey('note', $rest);
}
/** Ruling Q25: raw HTML next to the blocks is visible – such a copy is only "structure", never "identical". */
public function testExtraVisibleTextIsNotIdentical(): void
{
$root = LibraryContent::roots(LibraryContent::load(
new Parser(),
DiviFixtures::markup('stage3/library--library-abschnitt.html')
))[0];
$copy = Serializer::block($root);
$at = strrpos($copy, '<!-- /wp:divi/column -->');
$changed = substr($copy, 0, (int) $at) . '<p>ONLY ON 8</p>' . substr($copy, (int) $at);
LibraryFixtures::given([
LibraryFixtures::item(1055),
new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Über uns',
'post_content' => '<!-- wp:divi/placeholder -->' . $changed . '<!-- /wp:divi/placeholder -->']),
]);
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [
'edit_posts' => 'edit_posts',
'edit_others_posts' => 'edit_others_posts',
]]);
Functions\when('get_posts')->justReturn([8]);
$result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']);
self::assertSame(['8 s0 structure'], array_map(
static fn (array $m): string => $m['post_id'] . ' ' . $m['id'] . ' ' . $m['match'],
$result['matches']
));
}
}
@@ -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);
}
}
}