From e0683a19b1dbff43b0c77795ae4b87479a7972cc Mon Sep 17 00:00:00 2001 From: wpdev Date: Sat, 26 Sep 2026 00:04:38 +0800 Subject: [PATCH] feat(2meet-data-optimizer): reconcile post rows written to the wrong group table - TMDO_Post_Group_Reconciler copies non-NULL values that legacy routing stored in another group's table into the post type's own group (NULL/missing legacy values keep the scoped value), resumable via a cursor, with a catch-up pass for rows updated during the run, then switches on tmdo_post_scoped_routing - CLI: wp tmdo post-reconcile-groups [--dry-run] [--batch=]; registry exposes get_group_post_types() - Verify: PostGroupReconcilerPlanTest (4), PostGroupReconcilerTest (6); core unit 635 / integration 449, HP 145/49, WC 10/22 OK; read-only dry-run: dev20 320 values / 0 overwrites, dev21/22 none - PR: PR-034 --- 2meet-data-optimizer.php | 1 + cli/class-tmdo-cli-post.php | 46 ++++ .../engine/class-tmdo-entity-registry.php | 16 ++ .../class-tmdo-post-group-reconciler.php | 221 ++++++++++++++++++ tests/bootstrap.php | 1 + tests/integration/PostGroupReconcilerTest.php | 141 +++++++++++ tests/integration/bootstrap.php | 1 + tests/unit/PostGroupReconcilerPlanTest.php | 79 +++++++ 8 files changed, 506 insertions(+) create mode 100644 includes/migration/class-tmdo-post-group-reconciler.php create mode 100644 tests/integration/PostGroupReconcilerTest.php create mode 100644 tests/unit/PostGroupReconcilerPlanTest.php diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index 653c501..3fa1bc4 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -258,6 +258,7 @@ require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-cleanup.php require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php'; add_action( TMDO_Migration_Orchestrator::CRON_HOOK, array( 'TMDO_Migration_Orchestrator', 'cron_tick' ) ); add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) ); add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) ); diff --git a/cli/class-tmdo-cli-post.php b/cli/class-tmdo-cli-post.php index 717b525..1816e36 100644 --- a/cli/class-tmdo-cli-post.php +++ b/cli/class-tmdo-cli-post.php @@ -259,6 +259,50 @@ class TMDO_CLI_Post { ); } + // ── post-reconcile-groups ───────────────────────────────────────────────── + + /** + * Move post values written under legacy routing into their post type's + * group table, then switch on post_type-scoped routing. + * + * Needed once on sites that already hold post flat rows (the installer + * leaves tmdo_post_scoped_routing off there). Resumable: an interrupted + * run continues from its cursor. + * + * ## OPTIONS + * + * [--dry-run] + * : Count the values that would be copied / overwritten; write nothing. + * + * [--batch=] + * : Rows per batch. Default 500. + * + * ## EXAMPLES + * + * wp tmdo post-reconcile-groups --dry-run + * wp tmdo post-reconcile-groups + * + * @param array $args Positional arguments (unused). + * @param array $assoc_args Named arguments. + */ + public function post_reconcile_groups( $args, $assoc_args ): void { + $dry_run = isset( $assoc_args['dry-run'] ); + $report = TMDO_Post_Group_Reconciler::run( $dry_run, (int) ( $assoc_args['batch'] ?? 500 ) ); + + if ( empty( $report['moves'] ) ) { + WP_CLI::log( 'No shared post keys to move.' ); + } + foreach ( $report['moves'] as $m ) { + WP_CLI::log( sprintf( '%s → %s %s: %d value(s)%s', $m['source'], $m['target'], $m['key'], $m['copied'], $dry_run ? sprintf( ', %d would overwrite a different value', $m['overwritten'] ) : '' ) ); + } + + if ( $dry_run ) { + WP_CLI::success( '[dry-run] Nothing written. Re-run without --dry-run to copy and switch on scoped routing.' ); + return; + } + WP_CLI::success( 'Post groups reconciled; tmdo_post_scoped_routing is on.' ); + } + // ── post-cleanup (v2.9.3) ───────────────────────────────────────────────── /** @@ -853,3 +897,5 @@ WP_CLI::add_command( 'wpdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchm WP_CLI::add_command( 'tmdo post-benchmark', array( 'TMDO_CLI_Post', 'post_benchmark' ) ); WP_CLI::add_command( 'wpdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) ); WP_CLI::add_command( 'tmdo post-shadow-report', array( 'TMDO_CLI_Post', 'post_shadow_report' ) ); +WP_CLI::add_command( 'wpdo post-reconcile-groups', array( 'TMDO_CLI_Post', 'post_reconcile_groups' ) ); +WP_CLI::add_command( 'tmdo post-reconcile-groups', array( 'TMDO_CLI_Post', 'post_reconcile_groups' ) ); diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php index 8bcf47d..3e0a86f 100644 --- a/includes/engine/class-tmdo-entity-registry.php +++ b/includes/engine/class-tmdo-entity-registry.php @@ -37,6 +37,9 @@ final class TMDO_Entity_Registry { /** @var array>> 實體類型 → post_type → meta_key → 欄位定義 */ private static array $scoped_index = array(); + /** @var array> 實體類型 → 群組名 → 限定的 post_type */ + private static array $scopes = array(); + /** @var array "entity/group" → 同名群組以不同定義重複註冊的紀錄 */ private static array $collisions = array(); @@ -70,6 +73,7 @@ final class TMDO_Entity_Registry { self::$field_index = array(); self::$unscoped_index = array(); self::$scoped_index = array(); + self::$scopes = array(); self::$pending_schemas = array(); self::$collisions = array(); self::$restored = array(); @@ -144,6 +148,9 @@ final class TMDO_Entity_Registry { // 建立索引以供快速查詢 $post_types = array_values( array_filter( array_map( 'strval', (array) ( $args['post_types'] ?? array() ) ) ) ); + if ( $post_types ) { + self::$scopes[ $entity_type ][ $group_name ] = $post_types; + } foreach ( $normalized as $normalized_field ) { self::index_field( $entity_type, $normalized_field, $post_types ); } @@ -365,6 +372,15 @@ final class TMDO_Entity_Registry { return array_keys( self::$groups[ $entity_type ] ?? array() ); } + /** + * 群組限定的 post_type(未限定時為空陣列)。 + * + * @return string[] + */ + public static function get_group_post_types( string $entity_type, string $group_name ): array { + return self::$scopes[ $entity_type ][ $group_name ] ?? array(); + } + /** * 取得特定群組的完整欄位定義 */ diff --git a/includes/migration/class-tmdo-post-group-reconciler.php b/includes/migration/class-tmdo-post-group-reconciler.php new file mode 100644 index 0000000..4f4655b --- /dev/null +++ b/includes/migration/class-tmdo-post-group-reconciler.php @@ -0,0 +1,221 @@ + + */ + public static function plan(): array { + $moves = array(); + foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) { + $post_types = TMDO_Entity_Registry::get_group_post_types( 'post', $group ); + if ( empty( $post_types ) ) { + continue; + } + foreach ( TMDO_Entity_Registry::get_group_keys( 'post', $group ) as $key ) { + $legacy = TMDO_Entity_Registry::get_field( 'post', $key ); + if ( null === $legacy || $legacy['group'] === $group ) { + continue; + } + $moves[] = array( + 'target' => $group, + 'source' => $legacy['group'], + 'key' => $key, + 'post_types' => $post_types, + ); + } + } + return $moves; + } + + /** + * Copy the values (or, with $dry_run, count what would be copied). + * + * @param bool $dry_run Count only; write nothing. + * @param int $batch Rows per batch. + * @return array{moves: array, enabled: bool} + */ + public static function run( bool $dry_run = false, int $batch = 500 ): array { + global $wpdb; + + $report = array( + 'moves' => array(), + 'enabled' => false, + ); + $adapter = TMDO_Entity_Registry::get_adapter( 'post' ); + if ( ! $adapter ) { + return $report; + } + $id_col = $adapter->get_entity_id_column(); + $batch = max( 1, $batch ); + + if ( ! $dry_run && false === get_option( self::STARTED_OPTION ) ) { + update_option( self::STARTED_OPTION, (string) $wpdb->get_var( 'SELECT NOW()' ), false ); + } + $cursor = get_option( self::CURSOR_OPTION, array() ); + $cursor = is_array( $cursor ) ? $cursor : array(); + + $moves = self::plan(); + foreach ( $moves as $move ) { + list( $from, $params ) = self::source_clause( $move, $id_col ); + $entry = array( + 'target' => $move['target'], + 'source' => $move['source'], + 'key' => $move['key'], + 'copied' => 0, + 'overwritten' => 0, + ); + + if ( $dry_run ) { + $target = TMDO_Schema_Manager::get_table_name( 'post', $move['target'] ); + $col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] ); + + $entry['copied'] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) {$from}", ...$params ) ); + $entry['overwritten'] = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) {$from} AND EXISTS ( SELECT 1 FROM `{$target}` t WHERE t.`{$id_col}` = s.`{$id_col}` AND t.`{$col}` IS NOT NULL AND t.`{$col}` <> s.`{$col}` )", + ...$params + ) + ); + $report['moves'][] = $entry; + continue; + } + + $move_id = "{$move['source']}>{$move['target']}:{$move['key']}"; + $last = (int) ( $cursor[ $move_id ] ?? 0 ); + do { + $rows = (array) $wpdb->get_results( + $wpdb->prepare( "SELECT s.`{$id_col}` AS id, s.`" . TMDO_Schema_Manager::sanitize_column_name( $move['key'] ) . "` AS v {$from} AND s.`{$id_col}` > %d ORDER BY s.`{$id_col}` LIMIT %d", ...array_merge( $params, array( $last, $batch ) ) ), + ARRAY_A + ); + $entry['copied'] += self::copy_rows( $move, $id_col, $rows ); + $fetched = count( $rows ); + if ( $fetched ) { + $last = (int) end( $rows )['id']; + $cursor[ $move_id ] = $last; + update_option( self::CURSOR_OPTION, $cursor, false ); + } + } while ( $fetched === $batch ); + + $report['moves'][] = $entry; + } + + if ( $dry_run ) { + return $report; + } + + // Catch-up: legacy-routed writes that landed after a batch passed their row. + $started = (string) get_option( self::STARTED_OPTION ); + foreach ( $moves as $move ) { + list( $from, $params ) = self::source_clause( $move, $id_col ); + $rows = (array) $wpdb->get_results( + $wpdb->prepare( "SELECT s.`{$id_col}` AS id, s.`" . TMDO_Schema_Manager::sanitize_column_name( $move['key'] ) . "` AS v {$from} AND s.updated_at >= %s", ...array_merge( $params, array( $started ) ) ), + ARRAY_A + ); + self::copy_rows( $move, $id_col, $rows ); + } + + update_option( TMDO_Entity_Registry::SCOPED_ROUTING_OPTION, '1', true ); + delete_option( self::CURSOR_OPTION ); + delete_option( self::STARTED_OPTION ); + $report['enabled'] = true; + + return $report; + } + + /** + * FROM/WHERE for non-NULL legacy values of the move's post types. + * + * @param array $move One entry of plan(). + * @param string $id_col Entity id column of the flat tables. + * @return array{0:string, 1:array} + */ + private static function source_clause( array $move, string $id_col ): array { + global $wpdb; + $source = TMDO_Schema_Manager::get_table_name( 'post', $move['source'] ); + $col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] ); + $types = implode( ',', array_fill( 0, count( $move['post_types'] ), '%s' ) ); + + return array( + "FROM `{$source}` s INNER JOIN `{$wpdb->posts}` p ON p.ID = s.`{$id_col}` AND p.post_type IN ({$types}) WHERE s.`{$col}` IS NOT NULL", + $move['post_types'], + ); + } + + /** + * Upsert one column of the target group rows and drop their cached copies. + * + * @param array $move One entry of plan(). + * @param string $id_col Entity id column of the flat tables. + * @param array $rows Source rows: list of { id, v }. + * @return int Rows written. + */ + private static function copy_rows( array $move, string $id_col, array $rows ): int { + global $wpdb; + if ( empty( $rows ) ) { + return 0; + } + $target = TMDO_Schema_Manager::get_table_name( 'post', $move['target'] ); + $col = TMDO_Schema_Manager::sanitize_column_name( $move['key'] ); + + $values = array(); + $params = array(); + foreach ( $rows as $row ) { + $values[] = '(%d, %s)'; + $params[] = (int) $row['id']; + $params[] = (string) $row['v']; + } + $wpdb->query( + $wpdb->prepare( + "INSERT INTO `{$target}` (`{$id_col}`, `{$col}`) VALUES " . implode( ', ', $values ) . " ON DUPLICATE KEY UPDATE `{$col}` = VALUES(`{$col}`)", + ...$params + ) + ); + + if ( class_exists( 'TMDO_Cache_Orchestrator' ) ) { + foreach ( $rows as $row ) { + TMDO_Cache_Orchestrator::invalidate( 'post', (int) $row['id'], $move['target'] ); + } + } + return count( $rows ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8c1440e..275ab9b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -642,6 +642,7 @@ require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-cleanup.php require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php'; // Options Manager. require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php'; diff --git a/tests/integration/PostGroupReconcilerTest.php b/tests/integration/PostGroupReconcilerTest.php new file mode 100644 index 0000000..6c768e9 --- /dev/null +++ b/tests/integration/PostGroupReconcilerTest.php @@ -0,0 +1,141 @@ +query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', PRIMARY KEY (ID) ) ENGINE=InnoDB" ); + foreach ( array( 101 => 'hp_listing', 102 => 'hp_listing', 103 => 'hp_listing', 104 => 'hp_listing', 202 => 'hp_vendor' ) as $id => $type ) { + $wpdb->insert( $wpdb->posts, array( 'ID' => $id, 'post_type' => $type ) ); + } + $wpdb->query( 'CREATE TABLE `' . self::LISTING . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, price DECIMAL(18,6) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' ); + $wpdb->query( 'CREATE TABLE `' . self::VENDOR . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' ); + $wpdb->insert( self::VENDOR, array( 'post_id' => 101, 'shared_key' => 'live' ) ); + $wpdb->insert( self::VENDOR, array( 'post_id' => 102, 'shared_key' => 'only-in-vendor' ) ); + $wpdb->query( 'INSERT INTO `' . self::VENDOR . "` (post_id, shared_key) VALUES (104, NULL)" ); + $wpdb->insert( self::VENDOR, array( 'post_id' => 202, 'shared_key' => 'vendor own value' ) ); + $wpdb->insert( self::LISTING, array( 'post_id' => 101, 'shared_key' => 'backfill', 'price' => 5 ) ); + $wpdb->insert( self::LISTING, array( 'post_id' => 103, 'shared_key' => 'keep' ) ); + $wpdb->insert( self::LISTING, array( 'post_id' => 104, 'shared_key' => 'backfill-104' ) ); + // Historical rows: older than any run, so the catch-up pass leaves them alone. + $wpdb->query( 'UPDATE `' . self::VENDOR . "` SET updated_at = '2020-01-01 00:00:00'" ); + $wpdb->query( 'UPDATE `' . self::LISTING . "` SET updated_at = '2020-01-01 00:00:00'" ); + + $GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '0' ); + $GLOBALS['_wp_cache'] = array(); + + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() ); + WPDO_Entity_Registry::register_group( 'post', 'rc_listing', array( array( 'key' => 'shared_key', 'type' => 'text' ), array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) ); + WPDO_Entity_Registry::register_group( 'post', 'rc_vendor', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) ); + } + + protected function tearDown(): void { + self::drop(); + WPDO_Entity_Registry::init(); + } + + private static function drop(): void { + global $wpdb; + foreach ( array( $wpdb->posts, self::LISTING, self::VENDOR ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + } + + private function listing_value( int $post_id ): ?string { + global $wpdb; + return $wpdb->get_var( $wpdb->prepare( 'SELECT shared_key FROM `' . self::LISTING . '` WHERE post_id = %d', $post_id ) ); + } + + public function test_dry_run_reports_without_writing(): void { + $report = TMDO_Post_Group_Reconciler::run( true ); + + $this->assertSame( 2, $report['moves'][0]['copied'], '101 and 102 hold non-NULL legacy values' ); + $this->assertSame( 1, $report['moves'][0]['overwritten'], '101 has a different listing value' ); + $this->assertFalse( $report['enabled'] ); + $this->assertSame( 'backfill', $this->listing_value( 101 ) ); + $this->assertNull( $this->listing_value( 102 ) ); + $this->assertSame( '0', get_option( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ) ); + } + + public function test_run_moves_the_values_reads_returned_before_the_switch(): void { + $report = TMDO_Post_Group_Reconciler::run( false, 1 ); + + $this->assertSame( 'live', $this->listing_value( 101 ) ); + $this->assertSame( 'only-in-vendor', $this->listing_value( 102 ) ); + $this->assertSame( 'keep', $this->listing_value( 103 ) ); + $this->assertSame( 'backfill-104', $this->listing_value( 104 ), 'NULL legacy values do not erase the listing value' ); + $this->assertNull( $this->listing_value( 202 ), 'vendor posts are not copied into the listing table' ); + $this->assertSame( 2, $report['moves'][0]['copied'] ); + } + + public function test_other_columns_of_the_target_row_are_untouched(): void { + global $wpdb; + TMDO_Post_Group_Reconciler::run( false ); + + $this->assertSame( 5.0, (float) $wpdb->get_var( 'SELECT price FROM `' . self::LISTING . '` WHERE post_id = 101' ) ); + } + + public function test_completion_turns_scoped_routing_on_and_clears_the_cursor(): void { + $report = TMDO_Post_Group_Reconciler::run( false ); + + $this->assertTrue( $report['enabled'] ); + $this->assertSame( '1', get_option( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ) ); + $this->assertFalse( get_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION ) ); + } + + public function test_catch_up_copies_rows_written_during_the_run(): void { + global $wpdb; + // Simulates a legacy-routed write that lands after the batch passed post 101. + $wpdb->query( 'UPDATE `' . self::VENDOR . "` SET shared_key = 'written during run', updated_at = NOW() + INTERVAL 1 HOUR WHERE post_id = 101" ); + update_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION, array( 'rc_vendor>rc_listing:shared_key' => 101 ) ); + + TMDO_Post_Group_Reconciler::run( false ); + + $this->assertSame( 'written during run', $this->listing_value( 101 ) ); + } + + public function test_resumes_after_the_stored_cursor(): void { + update_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION, array( 'rc_vendor>rc_listing:shared_key' => 101 ) ); + + TMDO_Post_Group_Reconciler::run( false ); + + $this->assertSame( 'backfill', $this->listing_value( 101 ), 'rows up to the cursor were already done' ); + $this->assertSame( 'only-in-vendor', $this->listing_value( 102 ) ); + } +} diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 23616ef..0302d7d 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -602,6 +602,7 @@ require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-cleanup.php require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; +require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php'; require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php'; require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php'; require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php'; diff --git a/tests/unit/PostGroupReconcilerPlanTest.php b/tests/unit/PostGroupReconcilerPlanTest.php new file mode 100644 index 0000000..4b8f3e7 --- /dev/null +++ b/tests/unit/PostGroupReconcilerPlanTest.php @@ -0,0 +1,79 @@ +createMock( TMDO_Entity_Adapter_Interface::class ) ); + } + + public function test_plan_lists_shared_keys_whose_legacy_owner_is_another_group(): void { + TMDO_Entity_Registry::register_group( 'post', 'listing', array( array( 'key' => 'shared', 'type' => 'text' ), array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) ); + TMDO_Entity_Registry::register_group( 'post', 'vendor', array( array( 'key' => 'shared', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) ); + + $this->assertSame( + array( + array( + 'target' => 'listing', + 'source' => 'vendor', + 'key' => 'shared', + 'post_types' => array( 'hp_listing' ), + ), + ), + TMDO_Post_Group_Reconciler::plan() + ); + } + + public function test_unscoped_groups_and_unshared_keys_need_no_move(): void { + TMDO_Entity_Registry::register_group( 'post', 'site', array( array( 'key' => '_edit_lock', 'type' => 'text' ) ) ); + TMDO_Entity_Registry::register_group( 'post', 'listing', array( array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) ); + + $this->assertSame( array(), TMDO_Post_Group_Reconciler::plan() ); + } + + public function test_registry_exposes_group_post_types(): void { + TMDO_Entity_Registry::register_group( 'post', 'listing', array( array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) ); + TMDO_Entity_Registry::register_group( 'post', 'site', array( array( 'key' => '_edit_lock', 'type' => 'text' ) ) ); + + $this->assertSame( array( 'hp_listing' ), TMDO_Entity_Registry::get_group_post_types( 'post', 'listing' ) ); + $this->assertSame( array(), TMDO_Entity_Registry::get_group_post_types( 'post', 'site' ) ); + + TMDO_Entity_Registry::init(); + $this->assertSame( array(), TMDO_Entity_Registry::get_group_post_types( 'post', 'listing' ) ); + } + + public function test_core_hivepress_groups_plan_the_eight_misrouted_keys(): void { + TMDO_Post_Fields::register_entity_fields(); + + $moves = array(); + foreach ( TMDO_Post_Group_Reconciler::plan() as $m ) { + $moves[] = "{$m['source']}>{$m['target']}:{$m['key']}"; + } + sort( $moves ); + + $this->assertSame( + array( + 'hp_request_core>hp_listing_core:hp_expired_time', + 'hp_request_core>hp_listing_core:hp_status', + 'hp_request_core>hp_listing_core:hp_view_count', + 'hp_vendor_core>hp_listing_core:hp_hourly_rate', + 'hp_vendor_core>hp_listing_core:hp_rating', + 'hp_vendor_core>hp_listing_core:hp_rating_count', + 'hp_vendor_core>hp_listing_core:hp_verified', + 'hp_vendor_core>hp_request_core:hp_user', + ), + $moves + ); + } +}