diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index defb69a..f3de8ff 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -268,6 +268,7 @@ add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'b require_once TMDO_PATH . 'includes/class-tmdo-user-stress-tester.php'; require_once TMDO_PATH . 'includes/class-tmdo-post-stress-tester.php'; require_once TMDO_PATH . 'includes/class-tmdo-post-shadow-verifier.php'; +require_once TMDO_PATH . 'includes/diagnostic/class-tmdo-drift-check.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-auto-promoter.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-hook-bus.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php'; diff --git a/cli/class-tmdo-cli-v2.php b/cli/class-tmdo-cli-v2.php index 9da5a69..e99e9c3 100644 --- a/cli/class-tmdo-cli-v2.php +++ b/cli/class-tmdo-cli-v2.php @@ -186,6 +186,79 @@ class TMDO_CLI_V2 { WP_CLI::success( "{$entity}: {$from} → {$mode}" ); } + /** + * Compare an entity's flat tables with native meta (read-only). + * + * Reports per key how many values are missing from flat (miss), differ + * (diff) or exist only in flat (extra), and records the outcome per group + * in the wpdo_drift_status option. + * + * ## OPTIONS + * + * + * : post | user | term | comment + * + * [--group=] + * : Only this group. Default: every backfillable group. + * + * [--sample=] + * : Check n random entities per group instead of all of them. + * + * [--format=] + * : table or json. Default: table. + * + * ## EXAMPLES + * + * wp tmdo drift-check post + * wp tmdo drift-check user --group=activity + * wp tmdo drift-check post --sample=500 + * + * @param array $args Entity type. + * @param array $assoc_args Options. + */ + public function drift_check( $args, $assoc_args ): void { + $entity = (string) ( $args[0] ?? '' ); + $sample = max( 0, (int) ( $assoc_args['sample'] ?? 0 ) ); + + try { + $report = TMDO_Drift_Check::run( $entity, (string) ( $assoc_args['group'] ?? '' ), $sample ); + } catch ( InvalidArgumentException $e ) { + WP_CLI::error( $e->getMessage() ); + return; + } + + if ( 'json' === ( $assoc_args['format'] ?? 'table' ) ) { + WP_CLI::line( (string) wp_json_encode( $report ) ); + return; + } + + $rows = array(); + foreach ( $report['groups'] as $group => $result ) { + foreach ( $result['keys'] as $key => $counts ) { + if ( $counts['miss'] + $counts['diff'] + $counts['extra'] > 0 ) { + $rows[] = array_merge( + array( + 'group' => $group, + 'key' => $key, + ), + $counts + ); + } + } + } + if ( $rows ) { + \WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'key', 'miss', 'diff', 'extra' ) ); + } + + $total = $report['total']; + $summary = sprintf( '%s: %d group(s)%s — miss=%d diff=%d extra=%d', $entity, count( $report['groups'] ), $sample ? " (sample {$sample})" : '', $total['miss'], $total['diff'], $total['extra'] ); + if ( $total['miss'] + $total['diff'] + $total['extra'] > 0 ) { + WP_CLI::warning( $summary ); + return; + } + WP_CLI::success( $summary ); + } + /** * Write an entity's flat-table values back to native meta — the way out of * aeav_only. Resumable; re-run after an interruption. @@ -954,6 +1027,8 @@ WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) ); WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) ); WP_CLI::add_command( 'wpdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) ); WP_CLI::add_command( 'tmdo bridge-mode', array( new TMDO_CLI_V2(), 'bridge_mode' ) ); +WP_CLI::add_command( 'wpdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) ); +WP_CLI::add_command( 'tmdo drift-check', array( new TMDO_CLI_V2(), 'drift_check' ) ); WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) ); WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) ); WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) ); diff --git a/includes/diagnostic/class-tmdo-drift-check.php b/includes/diagnostic/class-tmdo-drift-check.php new file mode 100644 index 0000000..8353309 --- /dev/null +++ b/includes/diagnostic/class-tmdo-drift-check.php @@ -0,0 +1,240 @@ +}>, total:array{miss:int, diff:int, extra:int}} + * @throws InvalidArgumentException Unknown entity type or group. + */ + public static function run( string $entity_type, string $group = '', int $sample = 0 ): array { + $adapter = TMDO_Entity_Registry::get_adapter( $entity_type ); + if ( ! $adapter ) { + throw new InvalidArgumentException( 'Unknown entity type: ' . esc_html( $entity_type ) ); + } + $groups = TMDO_Entity_Migration_Engine::backfillable_groups( $entity_type ); + if ( '' !== $group ) { + if ( ! in_array( $group, $groups, true ) ) { + throw new InvalidArgumentException( 'Unknown or orphan ' . esc_html( $entity_type ) . ' group: ' . esc_html( $group ) ); + } + $groups = array( $group ); + } + + $report = array( + 'entity' => $entity_type, + 'sample' => $sample, + 'groups' => array(), + 'total' => array( + 'miss' => 0, + 'diff' => 0, + 'extra' => 0, + ), + ); + $status = get_option( self::OPTION, array() ); + $status = is_array( $status ) ? $status : array(); + + foreach ( $groups as $name ) { + $table = TMDO_Schema_Manager::get_table_name( $entity_type, $name ); + if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { + continue; + } + $result = self::check_group( $adapter, $entity_type, $name, $table, $sample ); + + $report['groups'][ $name ] = $result; + foreach ( array( 'miss', 'diff', 'extra' ) as $kind ) { + $report['total'][ $kind ] += $result[ $kind ]; + } + $status[ $entity_type ][ $name ] = array( + 'miss' => $result['miss'], + 'diff' => $result['diff'], + 'extra' => $result['extra'], + 'sample' => $sample, + 'checked_at' => gmdate( 'Y-m-d H:i:s' ), + ); + } + + update_option( self::OPTION, $status ); + return $report; + } + + /** + * Compare every key of one group. + * + * @param TMDO_Entity_Adapter_Interface $adapter Entity adapter. + * @param string $entity_type Entity type. + * @param string $group Group name. + * @param string $table Group flat table. + * @param int $sample 0, or the number of random entities. + * @return array{miss:int, diff:int, extra:int, keys:array} + */ + private static function check_group( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, string $table, int $sample ): array { + global $wpdb; + + $meta = $adapter->get_native_meta_table(); + $id = $adapter->get_entity_id_column(); + $mid = 'user' === $entity_type ? 'umeta_id' : 'meta_id'; + $scope = self::scope_sql( $adapter, $entity_type, $group, $sample ); + $result = array( + 'miss' => 0, + 'diff' => 0, + 'extra' => 0, + 'keys' => array(), + ); + + foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) { + $key = (string) $field['key']; + $col = TMDO_Schema_Manager::sanitize_column_name( $key ); + $type = $field['type'] ?? 'text'; + + // First native row per entity — what a single get_*_meta() read returns. + $native = $wpdb->prepare( "SELECT m.`{$id}` AS eid, m.meta_value AS v FROM `{$meta}` m JOIN ( SELECT `{$id}`, MIN(`{$mid}`) AS mid FROM `{$meta}` WHERE meta_key = %s GROUP BY `{$id}` ) x ON x.mid = m.`{$mid}` WHERE 1=1", $key ) + . str_replace( '{id}', "m.`{$id}`", $scope ); + + // Text beyond the column width is stored as NULL by design (Type_Caster::fit_text). + $fits = 'text' === $type ? ' AND CHAR_LENGTH(n.v) <= ' . TMDO_Type_Caster::TEXT_MAX_CHARS : ''; + $miss = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ( {$native} ) n LEFT JOIN `{$table}` f ON f.`{$id}` = n.eid WHERE LENGTH(n.v) > 0 AND f.`{$col}` IS NULL{$fits}" ); + + $candidates = $wpdb->get_results( + "SELECT n.v, f.`{$col}` AS flat FROM ( {$native} ) n JOIN `{$table}` f ON f.`{$id}` = n.eid + WHERE f.`{$col}` IS NOT NULL AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY n.v + OR ( n.v REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(n.v AS DECIMAL(30,6)) ) )", + ARRAY_A + ); + $diff = 0; + foreach ( (array) $candidates as $row ) { + if ( ! self::same( (string) $row['v'], (string) $row['flat'], $field ) ) { + ++$diff; + } + } + + $absent = $wpdb->prepare( "NOT EXISTS ( SELECT 1 FROM `{$meta}` m WHERE m.`{$id}` = f.`{$id}` AND m.meta_key = %s )", $key ); + if ( ! empty( $field['required'] ) ) { + $default = (string) TMDO_Type_Caster::to_db( null, $field ); + $absent .= $wpdb->prepare( + " AND NOT ( BINARY CAST(f.`{$col}` AS CHAR) = BINARY %s OR ( %s REGEXP '" . self::SQL_NUMERIC . "' AND CAST(f.`{$col}` AS DECIMAL(30,6)) = CAST(%s AS DECIMAL(30,6)) ) )", + $default, + $default, + $default + ); + } + // Every flat row counts, including rows of other post types or deleted + // entities; only sample mode narrows the rows. + $extra = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` f WHERE f.`{$col}` IS NOT NULL AND {$absent}" . ( $sample > 0 ? str_replace( '{id}', "f.`{$id}`", $scope ) : '' ) ); + + $result['keys'][ $key ] = array( + 'miss' => $miss, + 'diff' => $diff, + 'extra' => $extra, + ); + $result['miss'] += $miss; + $result['diff'] += $diff; + $result['extra'] += $extra; + } + + return $result; + } + + /** + * SQL restricting the checked entities: the group's post types, and in + * sample mode a random set of entity ids. `{id}` stands for the id column. + * + * @param TMDO_Entity_Adapter_Interface $adapter Entity adapter. + * @param string $entity_type Entity type. + * @param string $group Group name. + * @param int $sample 0, or the number of random entities. + */ + private static function scope_sql( TMDO_Entity_Adapter_Interface $adapter, string $entity_type, string $group, int $sample ): string { + global $wpdb; + + $post_types = 'post' === $entity_type ? TMDO_Entity_Registry::get_group_post_types( $entity_type, $group ) : array(); + $type_sql = $post_types + ? " post_type IN ('" . implode( "','", array_map( 'esc_sql', $post_types ) ) . "')" + : ''; + + if ( $sample <= 0 ) { + return $type_sql ? " AND {id} IN ( SELECT ID FROM {$wpdb->posts} WHERE{$type_sql} )" : ''; + } + + $primary = $adapter->get_primary_table(); + $pk = $adapter->get_primary_id_column(); + $ids = $wpdb->get_col( "SELECT `{$pk}` FROM `{$primary}`" . ( $type_sql ? " WHERE{$type_sql}" : '' ) . ' ORDER BY RAND() LIMIT ' . (int) $sample ); + return ' AND {id} IN (' . ( $ids ? implode( ',', array_map( 'intval', $ids ) ) : '0' ) . ')'; + } + + /** + * Whether a flat value is what native meta would store. + * + * @param string $native Raw native meta_value. + * @param string $flat Flat column value. + * @param array $field Registered field definition. + */ + private static function same( string $native, string $flat, array $field ): bool { + $type = $field['type'] ?? 'text'; + $value = TMDO_Safe_Unserialize::run( $native ); + if ( 'text' === $type && is_scalar( $value ) && mb_strlen( (string) $value ) > TMDO_Type_Caster::TEXT_MAX_CHARS ) { + return false; // Belongs in flat as NULL, yet this row holds a value. + } + + $expected = TMDO_Type_Caster::to_db( $value, $field ); + if ( null === $expected ) { + return false; + } + if ( 'json' === $type ) { + // Key order and int-vs-float formatting are not drift. + return json_decode( $flat, true ) == json_decode( (string) $expected, true ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + } + if ( is_numeric( $expected ) && is_numeric( $flat ) ) { + return abs( (float) $expected - (float) $flat ) < 0.000001; + } + return (string) $expected === $flat; + } +} diff --git a/includes/engine/class-tmdo-type-caster.php b/includes/engine/class-tmdo-type-caster.php index 44c2bff..9faa080 100644 --- a/includes/engine/class-tmdo-type-caster.php +++ b/includes/engine/class-tmdo-type-caster.php @@ -84,7 +84,7 @@ final class TMDO_Type_Caster { } /** 'text' columns are VARCHAR(255) (TMDO_Schema_Manager type map). */ - private const TEXT_MAX_CHARS = 255; + public const TEXT_MAX_CHARS = 255; /** * Keep a text value only if it fits its VARCHAR(255) column; otherwise diff --git a/tests/integration/DriftCheckTest.php b/tests/integration/DriftCheckTest.php new file mode 100644 index 0000000..a54ee1e --- /dev/null +++ b/tests/integration/DriftCheckTest.php @@ -0,0 +1,148 @@ +query( "CREATE TABLE `{$wpdb->users}` ( ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_login VARCHAR(60) NOT NULL DEFAULT '', PRIMARY KEY (ID) ) ENGINE=InnoDB" ); + $wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, num DECIMAL(18,6) DEFAULT NULL, cnt BIGINT(20) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, prefs TEXT DEFAULT NULL, meta_json LONGTEXT DEFAULT NULL, flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' ); + + for ( $id = 1; $id <= 7; $id++ ) { + $wpdb->insert( $wpdb->users, array( 'ID' => $id, 'user_login' => "u{$id}" ) ); + } + $prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) ); + $this->native( 1, array( 'num' => '50', 'cnt' => '3', 'name' => 'a', 'prefs' => $prefs, 'meta_json' => serialize( array( 'w' => 640, 'tags' => array( 'x' ) ) ) ) ); + $this->flat( array( 'user_id' => 1, 'num' => '50.000000', 'cnt' => 3, 'name' => 'a', 'prefs' => $prefs, 'meta_json' => '{"w":640,"tags":["x"]}' ) ); + $this->native( 2, array( 'name' => 'b', 'cnt' => '5' ) ); + $this->flat( array( 'user_id' => 2, 'name' => 'B', 'cnt' => 6 ) ); + $this->native( 3, array( 'name' => 'c' ) ); + $this->flat( array( 'user_id' => 3, 'cnt' => null ) ); + $this->flat( array( 'user_id' => 4, 'cnt' => 9 ) ); + $this->native( 5, array( 'name' => '' ) ); + $this->flat( array( 'user_id' => 5 ) ); + $this->native( 6, array( 'name' => str_repeat( 'x', 300 ) ) ); + $this->flat( array( 'user_id' => 6 ) ); + $this->native( 7, array( 'cnt' => '7' ) ); + + $GLOBALS['_wp_options'] = array(); + $GLOBALS['_wp_cache'] = array(); + WPDO_Entity_Registry::init(); + WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() ); + WPDO_Entity_Registry::register_group( + 'user', + 'dc_grp', + array( + array( 'key' => 'num', 'type' => 'decimal' ), + array( 'key' => 'cnt', 'type' => 'integer' ), + array( 'key' => 'name', 'type' => 'text' ), + array( 'key' => 'prefs', 'type' => 'textarea' ), + array( 'key' => 'meta_json', 'type' => 'json' ), + array( 'key' => 'flag', 'type' => 'integer', 'default' => 0, 'required' => true ), + ) + ); + WPDO_Schema_Manager::flush_table_exists_cache(); + } + + protected function tearDown(): void { + self::drop(); + WPDO_Entity_Registry::init(); + } + + private static function drop(): void { + global $wpdb; + foreach ( array( $wpdb->users, $wpdb->usermeta, self::TABLE ) as $table ) { + $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); + } + } + + private function native( int $user_id, array $values ): void { + global $wpdb; + foreach ( $values as $key => $value ) { + $wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) ); + } + } + + private function flat( array $row ): void { + global $wpdb; + $wpdb->insert( self::TABLE, $row ); + } + + private function snapshot(): string { + global $wpdb; + return md5( wp_json_encode( array( $wpdb->get_results( "SELECT * FROM `{$wpdb->usermeta}` ORDER BY umeta_id", ARRAY_A ), $wpdb->get_results( 'SELECT * FROM `' . self::TABLE . '` ORDER BY id', ARRAY_A ) ) ) ); + } + + public function test_counts_miss_diff_and_extra_per_key(): void { + $report = TMDO_Drift_Check::run( 'user' ); + + $this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] ); + $keys = $report['groups']['dc_grp']['keys']; + $this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 0 ), $keys['name'] ); + $this->assertSame( array( 'miss' => 1, 'diff' => 1, 'extra' => 1 ), $keys['cnt'] ); + foreach ( array( 'num', 'prefs', 'meta_json', 'flag' ) as $key ) { + $this->assertSame( array( 'miss' => 0, 'diff' => 0, 'extra' => 0 ), $keys[ $key ], "{$key}: equal by value" ); + } + } + + public function test_records_the_outcome_per_group(): void { + TMDO_Drift_Check::run( 'user', 'dc_grp' ); + + $status = get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']; + $this->assertSame( 2, $status['miss'] ); + $this->assertSame( 2, $status['diff'] ); + $this->assertSame( 1, $status['extra'] ); + $this->assertSame( 0, $status['sample'] ); + $this->assertNotEmpty( $status['checked_at'] ); + } + + public function test_a_sample_covering_every_user_matches_the_full_check(): void { + $report = TMDO_Drift_Check::run( 'user', '', 100 ); + + $this->assertSame( array( 'miss' => 2, 'diff' => 2, 'extra' => 1 ), $report['total'] ); + $this->assertSame( 100, get_option( TMDO_Drift_Check::OPTION )['user']['dc_grp']['sample'] ); + } + + public function test_never_writes_native_or_flat(): void { + $before = $this->snapshot(); + + TMDO_Drift_Check::run( 'user' ); + + $this->assertSame( $before, $this->snapshot() ); + } + + public function test_rejects_an_unknown_group(): void { + $this->expectException( InvalidArgumentException::class ); + TMDO_Drift_Check::run( 'user', 'no_such_group' ); + } +} diff --git a/tests/live/README.md b/tests/live/README.md index 602d5e3..2591e93 100644 --- a/tests/live/README.md +++ b/tests/live/README.md @@ -41,6 +41,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test: | `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) | | `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route, meta orderby: HivePress sort shape, named clause, meta_key + meta_value_num); routed SQL has no GROUP BY | | `rest` | anonymous GET of the public listings endpoints returns no protected or credential-like field (1.1.1) | +| `drift` | `TMDO_Drift_Check`: json groups show no false diff; a planted flat mismatch / flat-only value counts exactly once (1.2.0) | | `zero_side_effect` | zone tables that were never created are never queried on save / delete | ## Tools (`tools/`) diff --git a/tests/live/suites/drift.php b/tests/live/suites/drift.php new file mode 100644 index 0000000..9db544d --- /dev/null +++ b/tests/live/suites/drift.php @@ -0,0 +1,77 @@ + Report. + */ +function tmdo_live_drift( string $entity, string $group ): array { + $status = get_option( TMDO_Drift_Check::OPTION, null ); + try { + return TMDO_Drift_Check::run( $entity, $group ); + } finally { + null === $status ? delete_option( TMDO_Drift_Check::OPTION ) : update_option( TMDO_Drift_Check::OPTION, $status ); + } +} + +TMDO_Live::test( + 'drift-check reports no diff for json groups whose values match native', + static function () { + $checked = 0; + foreach ( array( 'attachment', 'nav_menu_item' ) as $group ) { + if ( ! in_array( $group, TMDO_Entity_Migration_Engine::backfillable_groups( 'post' ), true ) + || ! TMDO_Schema_Manager::table_exists( TMDO_Schema_Manager::get_table_name( 'post', $group ) ) ) { + continue; + } + $report = tmdo_live_drift( 'post', $group ); + tmdo_live_assert_same( 0, $report['total']['diff'], "post/{$group} diff" ); + ++$checked; + } + if ( 0 === $checked ) { + throw new TMDO_Live_Skip( 'no attachment / nav_menu_item flat table on this site' ); + } + } +); + +TMDO_Live::test( + 'drift-check counts a planted flat mismatch once and a flat-only value once', + static function () { + global $wpdb; + TMDO_Live_Fixtures::use_modes( array( 'user' => 'disabled' ) ); + $field = TMDO_Live_Fixtures::field( 'user', 'login_count' ); + $group = $field['group']; + $table = TMDO_Schema_Manager::get_table_name( 'user', $group ); + + $before = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count']; + + $mismatch = TMDO_Live_Fixtures::user(); + $flat_only = TMDO_Live_Fixtures::user(); + add_user_meta( $mismatch, 'login_count', '5' ); + try { + $wpdb->insert( $table, array( 'user_id' => $mismatch, 'login_count' => 6 ) ); + $wpdb->insert( $table, array( 'user_id' => $flat_only, 'login_count' => 9 ) ); + + $after = tmdo_live_drift( 'user', $group )['groups'][ $group ]['keys']['login_count']; + } finally { + $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table}` WHERE user_id IN (%d, %d)", $mismatch, $flat_only ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + tmdo_live_assert_same( $before['diff'] + 1, $after['diff'], 'login_count diff +1' ); + tmdo_live_assert_same( $before['extra'] + 1, $after['extra'], 'login_count extra +1' ); + tmdo_live_assert_same( $before['miss'], $after['miss'], 'login_count miss unchanged' ); + } +);