test: 移植 ListingStatsTest(9 tests)
Tests / PHP Lint (push) Successful in 6s
Tests / Unit Tests (push) Successful in 18s
Tests / Integration Tests (push) Successful in 21s

unit bootstrap 改載入真實 TMDO_Listing_Stats:先 define
TMDO_TEST_SKIP_LISTING_STATS_STUB,核心 bootstrap 便會跳過它的 stub。
145 → 154 tests。
This commit is contained in:
2026-07-31 10:41:13 +08:00
parent 59c9b698b7
commit 80ed7b1bb2
2 changed files with 257 additions and 3 deletions
+4 -3
View File
@@ -13,6 +13,9 @@
declare(strict_types=1); declare(strict_types=1);
// Load the real TMDO_Listing_Stats below instead of core's stub.
define( 'TMDO_TEST_SKIP_LISTING_STATS_STUB', true );
$tmdo_core_bootstrap = dirname( __DIR__, 2 ) . '/2meet-data-optimizer/tests/bootstrap.php'; $tmdo_core_bootstrap = dirname( __DIR__, 2 ) . '/2meet-data-optimizer/tests/bootstrap.php';
if ( ! file_exists( $tmdo_core_bootstrap ) ) { if ( ! file_exists( $tmdo_core_bootstrap ) ) {
throw new RuntimeException( throw new RuntimeException(
@@ -44,9 +47,7 @@ $tmdo_hp_files = array(
'includes/class-tmdo-hivepress-transient-filter.php', 'includes/class-tmdo-hivepress-transient-filter.php',
'includes/class-tmdo-hivepress-term-comment-fields.php', 'includes/class-tmdo-hivepress-term-comment-fields.php',
'includes/class-tmdo-hpct-import.php', 'includes/class-tmdo-hpct-import.php',
// class-tmdo-listing-stats.php is deliberately NOT loaded: the core unit 'includes/class-tmdo-listing-stats.php',
// bootstrap already declares a TMDO_Listing_Stats stub, and no test in this
// suite exercises the real implementation.
'admin/class-tmdo-admin-hivepress.php', 'admin/class-tmdo-admin-hivepress.php',
'cli/class-tmdo-cli-hivepress.php', 'cli/class-tmdo-cli-hivepress.php',
); );
+253
View File
@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for TMDO_Listing_Stats — Zone B/D integration.
*
* Tests view counter logic and flush-to-postmeta using
* the same in-memory wpdb mock as ZoneWarmTest.
*/
class ListingStatsTest extends TestCase {
/** In-memory warm store: post_id => meta_key => [value, expires_at] */
public static array $store = [];
/** Flushed view counts captured during flush_views_to_postmeta(). */
public static array $flushed = [];
protected function setUp(): void {
self::$store = [];
self::$flushed = [];
$GLOBALS['_wp_postmeta'] = [];
$this->setupWpdbMock();
}
private function setupWpdbMock(): void {
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public string $postmeta = 'wp_postmeta';
public string $posts = 'wp_posts';
public function prepare( string $sql, ...$args ): string {
$i = 0;
return preg_replace_callback( '/%([sd])/', function ( $m ) use ( &$i, $args ) {
$val = $args[ $i++ ] ?? '';
return $m[1] === 'd' ? (string) (int) $val : "'" . addslashes( (string) $val ) . "'";
}, $sql );
}
public function get_var( string $sql ): ?string {
$store = &ListingStatsTest::$store;
// Normalize whitespace so multiline SQL works with regex.
$flat = preg_replace( '/\s+/', ' ', $sql );
if ( preg_match( '/SELECT id.*post_id = (\d+).*meta_key = \'([^\']+)\'/', $flat, $m ) ) {
return isset( $store[ $m[1] ][ $m[2] ] ) ? '1' : null;
}
if ( preg_match( '/SELECT meta_value.*post_id = (\d+).*meta_key = \'([^\']+)\'/', $flat, $m ) ) {
$entry = $store[ $m[1] ][ $m[2] ] ?? null;
if ( ! $entry ) {
return null;
}
if ( $entry['expires_at'] && $entry['expires_at'] < time() ) {
return null;
}
return $entry['value'];
}
return null;
}
public function get_results( string $sql, $output = null ): array {
$flat = preg_replace( '/\s+/', ' ', $sql );
// Postmeta batch query (e.g. hp_view_count) → read from $GLOBALS['_wp_postmeta'].
if ( strpos( $flat, 'wp_postmeta' ) !== false ) {
$result = [];
foreach ( $GLOBALS['_wp_postmeta'] ?? [] as $post_id => $meta ) {
if ( isset( $meta['hp_view_count'] ) ) {
$row = [ 'post_id' => (string) $post_id, 'meta_value' => (string) $meta['hp_view_count'] ];
$result[] = $output === ARRAY_A ? $row : (object) $row;
}
}
return $result;
}
// Warm zone query → return from store, filtered by meta_key in SQL.
$store = ListingStatsTest::$store;
$result = [];
$filter_key = null;
if ( preg_match( "/meta_key = '([^']+)'/", $flat, $m ) ) {
$filter_key = $m[1];
}
foreach ( $store as $post_id => $keys ) {
foreach ( $keys as $meta_key => $entry ) {
if ( $filter_key && $meta_key !== $filter_key ) {
continue;
}
if ( $entry['expires_at'] && $entry['expires_at'] < time() ) {
continue;
}
$row = [ 'post_id' => $post_id, 'meta_key' => $meta_key, 'meta_value' => $entry['value'] ];
$result[] = $output === ARRAY_A ? $row : (object) $row;
}
}
return $result;
}
public function insert( string $table, array $data, $format = null ): int|false {
ListingStatsTest::$store[ $data['post_id'] ][ $data['meta_key'] ] = [
'value' => $data['meta_value'],
'expires_at' => isset( $data['expires_at'] ) ? strtotime( $data['expires_at'] ) : null,
];
return 1;
}
public function update( string $table, array $data, array $where, $f1 = null, $f2 = null ): int|false {
// Scan store and update matching entries (where clause uses id=1 placeholder).
foreach ( ListingStatsTest::$store as $post_id => &$keys ) {
foreach ( $keys as $meta_key => &$entry ) {
if ( isset( $data['meta_value'] ) ) {
$entry['value'] = $data['meta_value'];
}
if ( isset( $data['expires_at'] ) ) {
$entry['expires_at'] = strtotime( $data['expires_at'] );
}
}
}
return 1;
}
public function delete( string $table, array $where, $format = null ): int|false {
$post_id = $where['post_id'] ?? null;
$meta_key = $where['meta_key'] ?? null;
if ( $post_id && $meta_key ) {
unset( ListingStatsTest::$store[ $post_id ][ $meta_key ] );
} elseif ( $post_id ) {
unset( ListingStatsTest::$store[ $post_id ] );
}
return 1;
}
public function query( string $sql ): int|bool {
$store = &ListingStatsTest::$store;
$flat = preg_replace( '/\s+/', ' ', $sql );
// Atomic postmeta increment — from flush_views_to_postmeta (P1-22).
// SQL: UPDATE `wp_postmeta` SET meta_value = meta_value + N WHERE post_id = P AND meta_key = 'hp_view_count'
if ( stripos( $flat, 'meta_value = meta_value +' ) !== false ) {
if ( preg_match( '/meta_value = meta_value \+ (\d+).*post_id = (\d+)/i', $flat, $m ) ) {
$by = (int) $m[1];
$post_id = (int) $m[2];
if ( isset( $GLOBALS['_wp_postmeta'][ $post_id ]['hp_view_count'] ) ) {
$GLOBALS['_wp_postmeta'][ $post_id ]['hp_view_count'] += $by;
return 1;
}
}
return 0; // no existing row — caller will add_post_meta
}
// INSERT ... ON DUPLICATE KEY UPDATE (from set() via TMDO_DB::upsert())
if ( stripos( $flat, 'ON DUPLICATE KEY' ) !== false && stripos( $flat, 'COALESCE' ) === false ) {
if ( preg_match( "/VALUES \((\d+), '([^']+)', '([^']*)', (NULL|'[^']+'), '[^']+'\)/i", $flat, $m ) ) {
$post_id = (int) $m[1];
$meta_key = $m[2];
$meta_value = $m[3];
$expires_at = $m[4] === 'NULL' ? null : strtotime( trim( $m[4], "'" ) );
$store[ $post_id ][ $meta_key ] = [
'value' => $meta_value,
'expires_at' => $expires_at,
];
return 1;
}
}
// INSERT ... ON DUPLICATE KEY UPDATE meta_value = CAST(COALESCE...) + N (from increment())
if ( stripos( $flat, 'COALESCE' ) !== false ) {
if ( preg_match( "/VALUES \((\d+), '([^']+)', (\d+),/i", $flat, $m ) ) {
$post_id = (int) $m[1];
$meta_key = $m[2];
$by = (int) $m[3];
$current = (int) ( $store[ $post_id ][ $meta_key ]['value'] ?? 0 );
$store[ $post_id ][ $meta_key ] = [
'value' => (string) ( $current + $by ),
'expires_at' => null,
];
return 1;
}
}
return 0;
}
};
}
// ── increment_view ───────────────────────────────────────────────────────
public function test_increment_view_starts_at_one(): void {
TMDO_Listing_Stats::increment_view( 10 );
$this->assertSame( '1', TMDO_Zone_Warm::get( 10, TMDO_Listing_Stats::VIEW_KEY ) );
}
public function test_increment_view_accumulates(): void {
TMDO_Listing_Stats::increment_view( 10 );
TMDO_Listing_Stats::increment_view( 10 );
TMDO_Listing_Stats::increment_view( 10 );
$this->assertSame( '3', TMDO_Zone_Warm::get( 10, TMDO_Listing_Stats::VIEW_KEY ) );
}
// ── get_view_count ───────────────────────────────────────────────────────
public function test_get_view_count_reads_warm_zone_first(): void {
TMDO_Listing_Stats::increment_view( 20 );
TMDO_Listing_Stats::increment_view( 20 );
// postmeta has 0 (default), warm has 2.
$this->assertSame( 2, TMDO_Listing_Stats::get_view_count( 20 ) );
}
public function test_get_view_count_falls_back_to_postmeta(): void {
// No warm entry → falls back to postmeta.
$GLOBALS['_wp_postmeta'][30]['hp_view_count'] = 99;
$this->assertSame( 99, TMDO_Listing_Stats::get_view_count( 30 ) );
}
public function test_get_view_count_returns_zero_when_empty(): void {
$this->assertSame( 0, TMDO_Listing_Stats::get_view_count( 999 ) );
}
// ── flush_views_to_postmeta ──────────────────────────────────────────────
public function test_flush_moves_count_to_postmeta(): void {
TMDO_Listing_Stats::increment_view( 40 );
TMDO_Listing_Stats::increment_view( 40 );
$flushed = TMDO_Listing_Stats::flush_views_to_postmeta();
$this->assertSame( 1, $flushed );
$this->assertSame( 2, (int) ( $GLOBALS['_wp_postmeta'][40]['hp_view_count'] ?? 0 ) );
}
public function test_flush_clears_warm_entry(): void {
TMDO_Listing_Stats::increment_view( 50 );
TMDO_Listing_Stats::flush_views_to_postmeta();
$this->assertNull( TMDO_Zone_Warm::get( 50, TMDO_Listing_Stats::VIEW_KEY ) );
}
public function test_flush_returns_zero_when_no_warm_entries(): void {
$this->assertSame( 0, TMDO_Listing_Stats::flush_views_to_postmeta() );
}
public function test_flush_accumulates_on_top_of_existing_postmeta(): void {
$GLOBALS['_wp_postmeta'][60]['hp_view_count'] = 10;
TMDO_Listing_Stats::increment_view( 60 );
TMDO_Listing_Stats::increment_view( 60 );
TMDO_Listing_Stats::flush_views_to_postmeta();
$this->assertSame( 12, (int) ( $GLOBALS['_wp_postmeta'][60]['hp_view_count'] ?? 0 ) );
}
}