diff --git a/tests/Block_HTML_Sync_FiltersTest.php b/tests/Block_HTML_Sync_FiltersTest.php
new file mode 100644
index 000000000..de0b62144
--- /dev/null
+++ b/tests/Block_HTML_Sync_FiltersTest.php
@@ -0,0 +1,375 @@
+ [ 2, 3, '
My Heading
', 'My Heading
' ],
+ 'h3 to h1' => [ 3, 1, 'Main Title
', 'Main Title
' ],
+ 'h4 to h6' => [ 4, 6, 'Sub Sub Heading
', 'Sub Sub Heading
' ],
+ 'h6 to h2' => [ 6, 2, 'Deep Section
', 'Deep Section
' ],
+ 'h1 to h5' => [ 1, 5, 'Document Title
', 'Document Title
' ],
+ ];
+
+ foreach ( $transitions as $desc => $data ) {
+ list( $from_level, $to_level, $inner_html, $expected ) = $data;
+
+ $block = [
+ 'blockName' => 'core/heading',
+ 'attrs' => [ 'level' => $from_level ],
+ 'innerHTML' => $inner_html,
+ 'innerContent' => [ $inner_html ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_heading_level( $block, [ 'level' => $to_level ], 'core/heading' );
+
+ $this->assertSame( $expected, $result['innerHTML'], "Failed asserting innerHTML on {$desc}" );
+ $this->assertSame( [ $expected ], $result['innerContent'], "Failed asserting innerContent on {$desc}" );
+ }
+ }
+
+ /**
+ * Test that sync_heading_level preserves HTML attributes on the tag.
+ */
+ public function test_sync_heading_level_preserves_attributes() {
+ $inner_html = 'Heading Text
';
+ $expected = 'Heading Text
';
+
+ $block = [
+ 'blockName' => 'core/heading',
+ 'attrs' => [ 'level' => 2 ],
+ 'innerHTML' => $inner_html,
+ 'innerContent' => [ $inner_html ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_heading_level( $block, [ 'level' => 4 ], 'core/heading' );
+
+ $this->assertSame( $expected, $result['innerHTML'] );
+ $this->assertSame( [ $expected ], $result['innerContent'] );
+ }
+
+ /**
+ * Test that sync_heading_level preserves nested formatting tags and text.
+ */
+ public function test_sync_heading_level_preserves_nested_content() {
+ $inner_html = '';
+ $expected = '';
+
+ $block = [
+ 'blockName' => 'core/heading',
+ 'attrs' => [ 'level' => 2 ],
+ 'innerHTML' => $inner_html,
+ 'innerContent' => [ $inner_html ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_heading_level( $block, [ 'level' => 3 ], 'core/heading' );
+
+ $this->assertSame( $expected, $result['innerHTML'] );
+ $this->assertSame( [ $expected ], $result['innerContent'] );
+ }
+
+ /**
+ * Test that sync_heading_level ignores non-heading blocks.
+ */
+ public function test_sync_heading_level_ignores_non_heading_block() {
+ $block = [
+ 'blockName' => 'core/paragraph',
+ 'attrs' => [],
+ 'innerHTML' => 'Some paragraph text
',
+ 'innerContent' => [ 'Some paragraph text
' ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_heading_level( $block, [ 'level' => 3 ], 'core/paragraph' );
+
+ $this->assertSame( $block, $result );
+ }
+
+ /**
+ * Test that sync_heading_level returns unmodified block when level attribute is missing.
+ */
+ public function test_sync_heading_level_ignores_missing_level_attribute() {
+ $block = [
+ 'blockName' => 'core/heading',
+ 'attrs' => [ 'level' => 2 ],
+ 'innerHTML' => 'Heading
',
+ 'innerContent' => [ 'Heading
' ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_heading_level( $block, [ 'content' => 'Updated text' ], 'core/heading' );
+
+ $this->assertSame( $block, $result );
+ }
+
+ /**
+ * Test that sync_heading_level ignores out-of-range heading levels (< 1 or > 6).
+ */
+ public function test_sync_heading_level_ignores_invalid_levels() {
+ $invalid_levels = [ 0, -1, 7, 10 ];
+
+ foreach ( $invalid_levels as $invalid_level ) {
+ $block = [
+ 'blockName' => 'core/heading',
+ 'attrs' => [ 'level' => 2 ],
+ 'innerHTML' => 'Heading
',
+ 'innerContent' => [ 'Heading
' ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_heading_level( $block, [ 'level' => $invalid_level ], 'core/heading' );
+
+ $this->assertSame( $block, $result, "Failed on invalid level {$invalid_level}" );
+ }
+ }
+
+ /**
+ * Test that sync_heading_level handles empty innerHTML gracefully.
+ */
+ public function test_sync_heading_level_handles_empty_inner_html() {
+ $block_empty_string = [
+ 'blockName' => 'core/heading',
+ 'attrs' => [ 'level' => 2 ],
+ 'innerHTML' => '',
+ 'innerContent' => [],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_heading_level( $block_empty_string, [ 'level' => 3 ], 'core/heading' );
+ $this->assertSame( $block_empty_string, $result );
+
+ $block_missing_key = [
+ 'blockName' => 'core/heading',
+ 'attrs' => [ 'level' => 2 ],
+ ];
+
+ $result_missing = Block_HTML_Sync_Filters::sync_heading_level( $block_missing_key, [ 'level' => 3 ], 'core/heading' );
+ $this->assertSame( $block_missing_key, $result_missing );
+ }
+
+ /**
+ * Test that sync_heading_level leaves block intact when new level is identical to current.
+ */
+ public function test_sync_heading_level_unchanged_when_same_level() {
+ $inner_html = 'Current Level
';
+ $block = [
+ 'blockName' => 'core/heading',
+ 'attrs' => [ 'level' => 2 ],
+ 'innerHTML' => $inner_html,
+ 'innerContent' => [ $inner_html ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_heading_level( $block, [ 'level' => 2 ], 'core/heading' );
+
+ $this->assertSame( $inner_html, $result['innerHTML'] );
+ $this->assertSame( [ $inner_html ], $result['innerContent'] );
+ }
+
+ // =========================================================================
+ // Tests for sync_list_type()
+ // =========================================================================
+
+ /**
+ * Test that sync_list_type converts unordered list to ordered list.
+ */
+ public function test_sync_list_type_converts_unordered_to_ordered() {
+ $inner_html = '';
+ $expected = '- First
- Second
';
+
+ $block = [
+ 'blockName' => 'core/list',
+ 'attrs' => [ 'ordered' => false ],
+ 'innerHTML' => $inner_html,
+ 'innerContent' => [ $inner_html ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_list_type( $block, [ 'ordered' => true ], 'core/list' );
+
+ $this->assertSame( $expected, $result['innerHTML'] );
+ $this->assertSame( [ $expected ], $result['innerContent'] );
+ }
+
+ /**
+ * Test that sync_list_type converts ordered list to unordered list.
+ */
+ public function test_sync_list_type_converts_ordered_to_unordered() {
+ $inner_html = '- First
- Second
';
+ $expected = '';
+
+ $block = [
+ 'blockName' => 'core/list',
+ 'attrs' => [ 'ordered' => true ],
+ 'innerHTML' => $inner_html,
+ 'innerContent' => [ $inner_html ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_list_type( $block, [ 'ordered' => false ], 'core/list' );
+
+ $this->assertSame( $expected, $result['innerHTML'] );
+ $this->assertSame( [ $expected ], $result['innerContent'] );
+ }
+
+ /**
+ * Test that sync_list_type preserves list attributes.
+ */
+ public function test_sync_list_type_preserves_attributes() {
+ $inner_html = '';
+ $expected = '- Item
';
+
+ $block = [
+ 'blockName' => 'core/list',
+ 'attrs' => [ 'ordered' => false ],
+ 'innerHTML' => $inner_html,
+ 'innerContent' => [ $inner_html ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_list_type( $block, [ 'ordered' => true ], 'core/list' );
+
+ $this->assertSame( $expected, $result['innerHTML'] );
+ $this->assertSame( [ $expected ], $result['innerContent'] );
+ }
+
+ /**
+ * Test that sync_list_type preserves nested formatting and items.
+ */
+ public function test_sync_list_type_preserves_nested_items() {
+ $inner_html = '- Item 1 with link
- Item 2 with bold
';
+ $expected = '- Item 1 with link
- Item 2 with bold
';
+
+ $block = [
+ 'blockName' => 'core/list',
+ 'attrs' => [ 'ordered' => false ],
+ 'innerHTML' => $inner_html,
+ 'innerContent' => [ $inner_html ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_list_type( $block, [ 'ordered' => true ], 'core/list' );
+
+ $this->assertSame( $expected, $result['innerHTML'] );
+ $this->assertSame( [ $expected ], $result['innerContent'] );
+ }
+
+ /**
+ * Test that sync_list_type ignores non-list blocks.
+ */
+ public function test_sync_list_type_ignores_non_list_block() {
+ $block = [
+ 'blockName' => 'core/paragraph',
+ 'attrs' => [],
+ 'innerHTML' => 'Some text
',
+ 'innerContent' => [ 'Some text
' ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_list_type( $block, [ 'ordered' => true ], 'core/paragraph' );
+
+ $this->assertSame( $block, $result );
+ }
+
+ /**
+ * Test that sync_list_type returns unmodified block when ordered attribute is missing.
+ */
+ public function test_sync_list_type_ignores_missing_ordered_attribute() {
+ $block = [
+ 'blockName' => 'core/list',
+ 'attrs' => [ 'ordered' => false ],
+ 'innerHTML' => '',
+ 'innerContent' => [ '' ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_list_type( $block, [ 'reversed' => true ], 'core/list' );
+
+ $this->assertSame( $block, $result );
+ }
+
+ /**
+ * Test that sync_list_type handles empty innerHTML gracefully.
+ */
+ public function test_sync_list_type_handles_empty_inner_html() {
+ $block_empty_string = [
+ 'blockName' => 'core/list',
+ 'attrs' => [ 'ordered' => false ],
+ 'innerHTML' => '',
+ 'innerContent' => [],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_list_type( $block_empty_string, [ 'ordered' => true ], 'core/list' );
+ $this->assertSame( $block_empty_string, $result );
+
+ $block_missing_key = [
+ 'blockName' => 'core/list',
+ 'attrs' => [ 'ordered' => false ],
+ ];
+
+ $result_missing = Block_HTML_Sync_Filters::sync_list_type( $block_missing_key, [ 'ordered' => true ], 'core/list' );
+ $this->assertSame( $block_missing_key, $result_missing );
+ }
+
+ /**
+ * Test that sync_list_type returns unchanged block when list type matches requested state.
+ */
+ public function test_sync_list_type_unchanged_when_same_state() {
+ $inner_html = '';
+ $block = [
+ 'blockName' => 'core/list',
+ 'attrs' => [ 'ordered' => false ],
+ 'innerHTML' => $inner_html,
+ 'innerContent' => [ $inner_html ],
+ ];
+
+ $result = Block_HTML_Sync_Filters::sync_list_type( $block, [ 'ordered' => false ], 'core/list' );
+
+ $this->assertSame( $inner_html, $result['innerHTML'] );
+ $this->assertSame( [ $inner_html ], $result['innerContent'] );
+ }
+
+ // =========================================================================
+ // Tests for register()
+ // =========================================================================
+
+ /**
+ * Test that register() hooks both sync callbacks to wp_cli_post_block_update_html.
+ */
+ public function test_register_hooks() {
+ global $wpcli_entity_filter_test_log;
+
+ Block_HTML_Sync_Filters::register();
+
+ $this->assertIsArray( $wpcli_entity_filter_test_log );
+ $this->assertCount( 2, $wpcli_entity_filter_test_log );
+
+ $this->assertSame( 'wp_cli_post_block_update_html', $wpcli_entity_filter_test_log[0]['hook_name'] );
+ $this->assertSame( [ Block_HTML_Sync_Filters::class, 'sync_heading_level' ], $wpcli_entity_filter_test_log[0]['callback'] );
+ $this->assertSame( 10, $wpcli_entity_filter_test_log[0]['priority'] );
+ $this->assertSame( 3, $wpcli_entity_filter_test_log[0]['accepted_args'] );
+
+ $this->assertSame( 'wp_cli_post_block_update_html', $wpcli_entity_filter_test_log[1]['hook_name'] );
+ $this->assertSame( [ Block_HTML_Sync_Filters::class, 'sync_list_type' ], $wpcli_entity_filter_test_log[1]['callback'] );
+ $this->assertSame( 10, $wpcli_entity_filter_test_log[1]['priority'] );
+ $this->assertSame( 3, $wpcli_entity_filter_test_log[1]['accepted_args'] );
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 8a813c5fa..afacbde1c 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -14,6 +14,33 @@
require_once $autoloader;
}
+// Global stub for add_filter when running unit tests outside WordPress.
+if ( ! function_exists( 'add_filter' ) ) {
+ /**
+ * Stub add_filter for isolated unit testing.
+ *
+ * @param string $hook_name The filter hook name.
+ * @param callable $callback The callback function.
+ * @param int $priority Optional. Priority. Default 10.
+ * @param int $accepted_args Optional. Accepted arguments. Default 1.
+ * @return true
+ */
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Mocking WordPress core function for isolated testing.
+ function add_filter( $hook_name, $callback, $priority = 10, $accepted_args = 1 ) {
+ global $wpcli_entity_filter_test_log;
+ if ( ! is_array( $wpcli_entity_filter_test_log ) ) {
+ $wpcli_entity_filter_test_log = [];
+ }
+ $wpcli_entity_filter_test_log[] = [
+ 'hook_name' => $hook_name,
+ 'callback' => $callback,
+ 'priority' => $priority,
+ 'accepted_args' => $accepted_args,
+ ];
+ return true;
+ }
+}
+
// Load the polyfills - these are what we're testing.
// Note: We load polyfills.php directly to ensure our polyfill functions are defined
// even if the native PHP functions exist.