test(2meet-data-optimizer): add live test runner and failing shadow_read write suite

- Add tests/live: a `wp eval-file` runner (PASS/FAIL/XFAIL/XPASS/SKIP) with
  fixtures that override entity modes in process (pre_option) and read
  native / flat values with plain SQL, so WP core meta flows run for real
- shadow_write suite reproduces the dev36 finding: in shadow_read,
  update_*_meta returns false and native meta is never written
  (3 XFAIL marked for PR-056); the dual_write control passes
- Move the dev36 integration harness into tests/live/tools; outputs go to
  cwd / $TMDO_LIVE_OUT, site URL and path are env-configurable
- Verify: wp --path=<dev36> eval-file tests/live/run.php shadow_write
  → 1 passed, 3 xfail; unit 658 OK, integration 480 OK,
  phpcs (phpcs.xml) 0, stan OK
- PR: PR-052
This commit is contained in:
2026-09-26 18:21:31 +08:00
parent 2540d7c0e2
commit f72a315986
14 changed files with 901 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* Minimal assertion + test registry for the live suite.
*
* Why not PHPUnit: the live suite runs inside a real WordPress request
* (`wp eval-file`) so WP core's update_metadata() flow, WP_Query SQL and
* third-party hooks (HivePress, WooCommerce) are exercised for real — the
* stubbed integration bootstrap cannot reproduce those.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
/**
* Thrown by the assert helpers.
*/
class TMDO_Live_Failure extends RuntimeException {}
/**
* Thrown to skip a test whose preconditions the site does not meet.
*/
class TMDO_Live_Skip extends RuntimeException {}
/**
* Test registry and runner.
*/
final class TMDO_Live {
/**
* Registered tests.
*
* @var array<int, array{suite:string, name:string, fn:callable, xfail:string}>
*/
private static array $tests = array();
/**
* Suite currently being loaded.
*
* @var string
*/
public static string $suite = '';
/**
* Register a test.
*
* `xfail` marks a known bug: a failure is reported as XFAIL (not counted),
* a pass is reported as XPASS and counted as a failure so the marker gets
* removed by the PR that fixes the bug.
*
* @param string $name Test name.
* @param callable $fn Test body.
* @param array $opts Options: xfail => reason.
*/
public static function test( string $name, callable $fn, array $opts = array() ): void {
self::$tests[] = array(
'suite' => self::$suite,
'name' => $name,
'fn' => $fn,
'xfail' => (string) ( $opts['xfail'] ?? '' ),
);
}
/**
* Run every registered test; return true when nothing failed.
*/
public static function run(): bool {
$counts = array( 'PASS' => 0, 'FAIL' => 0, 'XFAIL' => 0, 'XPASS' => 0, 'SKIP' => 0 );
foreach ( self::$tests as $t ) {
$detail = '';
try {
( $t['fn'] )();
$status = '' !== $t['xfail'] ? 'XPASS' : 'PASS';
} catch ( TMDO_Live_Skip $e ) {
$status = 'SKIP';
$detail = $e->getMessage();
} catch ( Throwable $e ) {
$status = '' !== $t['xfail'] ? 'XFAIL' : 'FAIL';
$detail = ( $e instanceof TMDO_Live_Failure ? '' : get_class( $e ) . ': ' ) . $e->getMessage();
} finally {
TMDO_Live_Fixtures::teardown();
}
++$counts[ $status ];
if ( '' !== $t['xfail'] ) {
$detail = trim( $detail . ' [xfail: ' . $t['xfail'] . ']' );
}
printf( "%-5s %s :: %s%s\n", $status, $t['suite'], $t['name'], '' !== $detail ? "\n " . $detail : '' );
}
printf(
"\n%d passed, %d failed, %d xfail, %d xpass, %d skipped\n",
$counts['PASS'],
$counts['FAIL'],
$counts['XFAIL'],
$counts['XPASS'],
$counts['SKIP']
);
return 0 === $counts['FAIL'] && 0 === $counts['XPASS'];
}
}
/**
* Fail unless $actual is identical to $expected.
*
* @param mixed $expected Expected value.
* @param mixed $actual Actual value.
* @param string $message Context.
*/
function tmdo_live_assert_same( $expected, $actual, string $message ): void {
if ( $expected !== $actual ) {
throw new TMDO_Live_Failure(
sprintf( '%s — expected %s, got %s', $message, var_export( $expected, true ), var_export( $actual, true ) ) // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
);
}
}
/**
* Fail unless $condition is true.
*
* @param bool $condition Condition.
* @param string $message Context.
*/
function tmdo_live_assert_true( bool $condition, string $message ): void {
if ( ! $condition ) {
throw new TMDO_Live_Failure( $message );
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
/**
* Live-suite fixtures: in-process mode overrides, throwaway entities, and
* direct reads of native meta / flat tables (bypassing every meta filter).
*
* Nothing here persists a mode change: modes are overridden through the
* `pre_option_wpdo_bridge_modes` filter for the current process only.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
/**
* Fixture helpers, reset after every test by TMDO_Live::run().
*/
final class TMDO_Live_Fixtures {
/**
* Entities created by the current test, deleted on teardown.
*
* @var array<int, array{0:string, 1:int, 2?:string}>
*/
private static array $created = array();
/**
* Active pre_option callback, if any.
*
* @var callable|null
*/
private static $mode_filter = null;
/**
* Override entity modes for this process and make sure the Hook Bus is hooked.
*
* @param array<string, string> $modes e.g. array( 'user' => 'shadow_read' ).
*/
public static function use_modes( array $modes ): void {
self::clear_mode_filter();
$stored = get_option( 'wpdo_bridge_modes', array() );
$merged = array_merge( is_array( $stored ) ? $stored : array(), $modes );
self::$mode_filter = static function () use ( $merged ) {
return $merged;
};
add_filter( 'pre_option_wpdo_bridge_modes', self::$mode_filter );
self::reset_caches();
// The Hook Bus only boots when wpdo_hook_bus_enabled is on; the suite
// must not depend on (or change) that site option.
if ( false === has_filter( 'update_user_metadata', array( 'TMDO_Hook_Bus', 'intercept_update' ) ) ) {
TMDO_Hook_Bus::init();
}
}
/**
* Create a throwaway user.
*/
public static function user(): int {
$login = 'tmdo_live_' . wp_generate_password( 6, false, false );
$id = wp_insert_user(
array(
'user_login' => $login,
'user_email' => $login . '@example.test',
'user_pass' => wp_generate_password(),
)
);
if ( is_wp_error( $id ) ) {
throw new TMDO_Live_Failure( 'fixture user: ' . $id->get_error_message() );
}
self::$created[] = array( 'user', (int) $id );
return (int) $id;
}
/**
* Create a throwaway term.
*
* @param string $taxonomy Taxonomy.
*/
public static function term( string $taxonomy ): int {
if ( ! taxonomy_exists( $taxonomy ) ) {
throw new TMDO_Live_Skip( "taxonomy {$taxonomy} not registered on this site" );
}
$t = wp_insert_term( 'tmdo-live-' . wp_generate_password( 6, false, false ), $taxonomy );
if ( is_wp_error( $t ) ) {
throw new TMDO_Live_Failure( 'fixture term: ' . $t->get_error_message() );
}
self::$created[] = array( 'term', (int) $t['term_id'], $taxonomy );
return (int) $t['term_id'];
}
/**
* Native meta values for a key, read with SQL (no filters, no cache).
*
* @param string $type Entity type (post|user|term|comment).
* @param int $id Entity ID.
* @param string $key Meta key.
* @return string[]
*/
public static function native( string $type, int $id, string $key ): array {
global $wpdb;
$table = _get_meta_table( $type );
$id_col = $type . '_id';
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column names are WP internals.
return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$table} WHERE {$id_col} = %d AND meta_key = %s ORDER BY {$pk}", $id, $key ) );
}
/**
* Raw flat-table value for a key; 'NO-ROW' when the entity has no row.
*
* @param string $type Entity type.
* @param int $id Entity ID.
* @param string $key Meta key.
* @return string|null
*/
public static function flat( string $type, int $id, string $key ) {
global $wpdb;
$field = self::field( $type, $key );
$table = TMDO_Schema_Manager::get_table_name( $type, $field['group'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column come from the registry.
$row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$col}` AS v FROM `{$table}` WHERE {$type}_id = %d", $id ), ARRAY_A );
return null === $row ? 'NO-ROW' : $row['v'];
}
/**
* Registry field for a key, or skip the test when the site does not register it.
*
* @param string $type Entity type.
* @param string $key Meta key.
*/
public static function field( string $type, string $key ): array {
$field = TMDO_Entity_Registry::get_field( $type, $key );
if ( ! $field ) {
throw new TMDO_Live_Skip( "{$type} key {$key} is not registered on this site" );
}
if ( ! TMDO_Schema_Manager::table_exists( TMDO_Schema_Manager::get_table_name( $type, $field['group'] ) ) ) {
throw new TMDO_Live_Skip( "flat table for {$type}/{$field['group']} does not exist" );
}
return $field;
}
/**
* Delete fixtures and drop the mode override.
*/
public static function teardown(): void {
require_once ABSPATH . 'wp-admin/includes/user.php';
foreach ( array_reverse( self::$created ) as $c ) {
if ( 'user' === $c[0] ) {
wp_delete_user( $c[1] );
} elseif ( 'term' === $c[0] ) {
wp_delete_term( $c[1], $c[2] );
}
}
self::$created = array();
self::clear_mode_filter();
self::reset_caches();
}
/**
* Remove the pre_option override, if any.
*/
private static function clear_mode_filter(): void {
if ( null !== self::$mode_filter ) {
remove_filter( 'pre_option_wpdo_bridge_modes', self::$mode_filter );
self::$mode_filter = null;
}
}
/**
* Reset request-level caches that memoise modes or routing decisions.
*/
private static function reset_caches(): void {
TMDO_Mode_Manager::reset_cache();
TMDO_Routing_Predicate::flush_cache();
}
}