feat: M4 in-admin manual generation on chained-cron checkpoints + IA/perf pass (v0.21.0) (#166)

Final Phase 2 admin milestone. Board "New Article" runs through the M3 chained-cron checkpoint state machine (one stage/idea per tick, queue persisted before generate, lock released on every exit path) so generation survives Hostinger background-kill; retires the SSH-only do_action workaround (WP-CLI routes through the same entry). IA/perf: settings-backed board/ideas pagination + db 1.4.0 indexes. Folds guard P2s (--self-test + window doc).

QA: APPROVE-WITH-P2-NOTES @ f2a00d4 + delta APPROVE @ 0c8071f. Deferred (tracked): class-admin-page.php 353-line split + pagination Settings-UI fields.

Agent-Session: phase2-admin-m4-20260614
This commit is contained in:
peptiderepo
2026-06-15 00:48:43 +07:00
committed by GitHub
parent 326a1af900
commit d770e8a86a
30 changed files with 1457 additions and 251 deletions

View File

@@ -209,6 +209,11 @@ if ( ! class_exists( 'WP_Post' ) ) {
// Plugin URL and version constants used by admin enqueue methods.
// Defined here so all tests that load admin classes can stub WP enqueue calls
// without hitting "Undefined constant" errors.
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT', 20 );
}
// Plugin URL and version constants used by admin enqueue methods.
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_URL' ) ) {
define( 'PRAUTOBLOGGER_PLUGIN_URL', 'https://test.example.com/wp-content/plugins/prautoblogger/' );
}

View File

@@ -0,0 +1,239 @@
<?php
/**
* Tests for PRAutoBlogger_Board_Gen_Log_Query — binding order and result shape.
*
* The key regression: both get_generating_cards() and get_failed_cards() had
* (a) a missing comma between bound args and (b) reversed argument order — the
* integer limit was passed first, the datetime string second, which bound
* created_at >= <int> and LIMIT <datetime>. These tests assert the correct
* binding order (string first, int second) so any future regression is caught
* before CI.
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class BoardGenLogQueryTest extends BaseTestCase {
/**
* Captured prepare() calls: each entry is ['sql' => string, 'args' => array].
*
* @var array<int, array{sql: string, args: array}>
*/
private array $prepare_calls = array();
/**
* Build a fresh mock $wpdb whose get_results() always returns $rows.
*
* @param array $rows Rows to return from get_results().
* @return \PHPUnit\Framework\MockObject\MockObject
*/
private function make_wpdb( array $rows = array() ): object {
$wpdb = $this->create_mock_wpdb();
$wpdb->prefix = 'wp_';
$GLOBALS['wpdb'] = $wpdb;
$self = $this;
$wpdb->method( 'prepare' )->willReturnCallback(
static function ( string $sql, ...$args ) use ( $self ) {
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
$args = $args[0];
}
$self->prepare_calls[] = array( 'sql' => $sql, 'args' => $args );
return 'PREPARED_SQL';
}
);
$wpdb->method( 'get_results' )->willReturn( $rows );
return $wpdb;
}
protected function setUp(): void {
parent::setUp();
$this->prepare_calls = array();
Functions\when( 'get_transient' )->justReturn( false );
Functions\when( 'admin_url' )->alias(
function ( string $path = '' ) {
return 'https://test.example.com/wp-admin/' . ltrim( $path, '/' );
}
);
Functions\when( 'get_option' )->alias(
function ( string $name, $default = false ) {
if ( 'prautoblogger_board_column_limit' === $name ) {
return 10;
}
return $default;
}
);
}
protected function tearDown(): void {
$this->prepare_calls = array();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
// ── get_generating_cards() binding order ────────────────────────────────
/**
* get_generating_cards() must pass $since (string) as the FIRST bound arg
* and the integer limit as the SECOND — matching placeholder order %s … %d.
*
* This would have caught the reversed-order bug (limit int passed first,
* datetime string second) that silently bound created_at >= <int>.
*/
public function test_generating_cards_prepare_arg_order(): void {
$this->make_wpdb();
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
$query->get_generating_cards();
// Find the prepare() call containing LIMIT %d (the gen-log secondary query).
$gen_call = null;
foreach ( $this->prepare_calls as $call ) {
if (
str_contains( $call['sql'], 'LIMIT %d' ) &&
str_contains( $call['sql'], 'post_id IS NULL' ) &&
! str_contains( $call['sql'], "response_status = 'error'" )
) {
$gen_call = $call;
break;
}
}
$this->assertNotNull( $gen_call, 'get_generating_cards() must call $wpdb->prepare() with LIMIT %d' );
$this->assertCount( 2, $gen_call['args'], 'prepare() must receive exactly 2 bound args' );
// First arg must be a string (the $since datetime).
$this->assertIsString( $gen_call['args'][0], 'First arg to prepare() must be the $since string (for %s)' );
// Second arg must be an integer (the column limit).
$this->assertIsInt( $gen_call['args'][1], 'Second arg to prepare() must be the integer limit (for %d)' );
}
/**
* get_generating_cards() must return an empty array (not error) when DB returns nothing.
*/
public function test_generating_cards_returns_empty_array_on_no_results(): void {
$this->make_wpdb();
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
$cards = $query->get_generating_cards();
$this->assertIsArray( $cards );
}
/**
* When the status transient signals a running job, get_generating_cards()
* returns a card from the transient (no DB call needed for that card).
*/
public function test_generating_cards_returns_transient_card_when_running(): void {
$this->make_wpdb();
Functions\when( 'get_transient' )->alias(
function ( $key ) {
if ( 'prautoblogger_generation_status' === $key ) {
return array(
'status' => 'running',
'stage' => 'Drafting…',
'started' => time() - 30,
);
}
return false;
}
);
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
$cards = $query->get_generating_cards();
$this->assertNotEmpty( $cards, 'Transient running state must produce at least one card' );
$this->assertSame( '', $cards[0]['run_id'], 'Transient card has empty run_id' );
$this->assertSame( 'logs', $cards[0]['click_action'] );
}
// ── get_failed_cards() binding order ────────────────────────────────────
/**
* get_failed_cards() must pass $since (string) as the FIRST bound arg
* and the integer limit as the SECOND — matching placeholder order %s … %d.
*/
public function test_failed_cards_prepare_arg_order(): void {
$this->make_wpdb();
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
$query->get_failed_cards();
// Find the prepare() call for the failed query.
$fail_call = null;
foreach ( $this->prepare_calls as $call ) {
if ( str_contains( $call['sql'], "response_status = 'error'" ) ) {
$fail_call = $call;
break;
}
}
$this->assertNotNull( $fail_call, 'get_failed_cards() must call $wpdb->prepare() with response_status filter' );
$this->assertCount( 2, $fail_call['args'], 'prepare() must receive exactly 2 bound args' );
// First arg must be a string (the $since datetime).
$this->assertIsString( $fail_call['args'][0], 'First arg to prepare() must be the $since string (for %s)' );
// Second arg must be an integer (the column limit).
$this->assertIsInt( $fail_call['args'][1], 'Second arg to prepare() must be the integer limit (for %d)' );
}
/**
* get_failed_cards() returns the correct card shape from a seeded result row.
*/
public function test_failed_cards_returns_correct_shape(): void {
// Use a fresh wpdb with non-empty get_results rows.
$this->make_wpdb(
array(
array(
'run_id' => 'fail-run-abc',
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 3600 ),
'last_error' => 'Rate limit exceeded',
'last_stage' => 'draft',
'call_count' => '3',
),
)
);
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
$cards = $query->get_failed_cards();
$this->assertCount( 1, $cards );
$this->assertSame( 'fail-run-abc', $cards[0]['run_id'] );
$this->assertStringContainsString( 'Rate limit', $cards[0]['error_message'] );
$this->assertSame( 'logs', $cards[0]['click_action'] );
$this->assertArrayHasKey( 'started_at', $cards[0] );
}
/**
* Column-limit option is honoured: limit 7 → second arg to prepare() is 7.
*/
public function test_column_limit_option_is_respected(): void {
Functions\when( 'get_option' )->alias(
function ( string $name, $default = false ) {
if ( 'prautoblogger_board_column_limit' === $name ) {
return 7;
}
return $default;
}
);
$this->make_wpdb();
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
$query->get_generating_cards();
$gen_call = null;
foreach ( $this->prepare_calls as $call ) {
if ( str_contains( $call['sql'], 'LIMIT %d' ) && ! str_contains( $call['sql'], "response_status = 'error'" ) ) {
$gen_call = $call;
break;
}
}
$this->assertNotNull( $gen_call );
$this->assertSame( 7, $gen_call['args'][1], 'Limit arg must match the option value' );
}
}

View File

@@ -54,6 +54,15 @@ class BoardHumanModifiedFlagTest extends BaseTestCase {
);
Functions\when( 'get_edit_post_link' )->justReturn( 'https://example.com/edit' );
Functions\when( 'admin_url' )->alias( static fn( $p = '' ) => 'https://example.com/wp-admin/' . $p );
Functions\when( 'get_option' )->alias(
function ( string $name, $default = false ) {
$map = array(
'prautoblogger_board_column_limit' => PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT,
'prautoblogger_board_published_window_days' => 7,
);
return $map[ $name ] ?? $default;
}
);
\PRAutoBlogger_Run_Stage_State::flush_cache();
}

View File

@@ -0,0 +1,230 @@
<?php
/**
* Tests for PRAutoBlogger_Generation_Checkpoint_Runner (v0.21.0, M4).
*
* Locks the chained-cron checkpoint contract:
* kick_off() -- schedules cron + writes initial transient, no pipeline call.
* on_generate_tick() -- pops ONE idea, reschedules or finalizes; halted run aborts early.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class GenerationCheckpointRunnerTest extends BaseTestCase {
/** @var string[] Cron hooks captured from wp_schedule_single_event(). */
private array $scheduled = array();
/** @var array<string, mixed> Option writes captured from update_option(). */
private array $options = array();
/** @var array<int, array> Transient writes captured from set_transient(). */
private array $transients = array();
protected function setUp(): void {
parent::setUp();
$this->scheduled = array();
$this->options = array();
$this->transients = array();
Functions\when( 'wp_schedule_single_event' )->alias(
function ( $ts, $hook, $args = array() ) {
$this->scheduled[] = $hook;
return true;
}
);
Functions\when( 'wp_next_scheduled' )->justReturn( false );
Functions\when( 'spawn_cron' )->justReturn( true );
Functions\when( 'wp_remote_post' )->justReturn( array() );
Functions\when( 'site_url' )->justReturn( 'https://example.com' );
Functions\when( 'set_transient' )->alias(
function ( $key, $value, $ttl = 0 ) {
$this->transients[] = array( 'key' => $key, 'value' => (array) $value );
return true;
}
);
Functions\when( 'update_option' )->alias(
function ( $name, $value, $autoload = null ) {
$this->options[ $name ] = $value;
return true;
}
);
Functions\when( 'delete_option' )->alias(
function ( $name ) {
unset( $this->options[ $name ] );
return true;
}
);
Functions\when( 'get_option' )->alias(
function ( $name, $default = false ) {
return $this->options[ $name ] ?? $default;
}
);
Functions\when( 'wp_generate_uuid4' )->justReturn( 'test-run-uuid-001' );
// Monthly budget 0 = no limit.
$this->options['prautoblogger_monthly_budget_usd'] = '0';
Functions\when( 'wp_cache_get' )->justReturn( false );
Functions\when( 'wp_cache_set' )->justReturn( true );
Functions\when( 'wp_cache_delete' )->justReturn( true );
\PRAutoBlogger_Run_State::flush_cache();
}
protected function tearDown(): void {
\PRAutoBlogger_Run_State::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/** Stub the generation lock to always succeed. */
private function wire_wpdb_for_lock(): void {
$wpdb = $this->create_mock_wpdb();
$wpdb->options = 'wp_options';
$GLOBALS['wpdb'] = $wpdb;
$wpdb->method( 'query' )->willReturn( 1 );
$wpdb->method( 'get_var' )->willReturn( null );
}
/** Build a minimal Article_Idea-compatible array. */
private function make_idea( string $topic = 'Test Topic' ): array {
return array(
'topic' => $topic,
'article_type' => 'guide',
'suggested_title' => 'Guide to ' . $topic,
'summary' => '',
'score' => 0.9,
'analysis_id' => 1,
'source_ids' => array(),
'key_points' => array(),
'target_keywords' => array(),
);
}
// ── kick_off() ────────────────────────────────────────────────────────
/**
* kick_off() schedules exactly the ORCHESTRATE cron action and writes
* an initial 'running' status transient. The pipeline is NOT called.
*/
public function test_kick_off_schedules_orchestrate_cron_and_writes_initial_transient(): void {
\PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
$this->assertContains(
\PRAutoBlogger_Generation_Checkpoint_Runner::ORCHESTRATE_ACTION,
$this->scheduled,
'kick_off() must schedule ORCHESTRATE_ACTION'
);
$found_running_transient = false;
foreach ( $this->transients as $t ) {
if ( isset( $t['value']['status'] ) && 'running' === $t['value']['status'] ) {
$found_running_transient = true;
break;
}
}
$this->assertTrue( $found_running_transient, 'kick_off() must write a running status transient' );
}
// ── on_generate_tick() ────────────────────────────────────────────────
/**
* An empty queue causes generate tick to finalize without scheduling
* another GENERATE action (no Article_Worker call).
*/
public function test_generate_tick_finalizes_when_queue_is_empty(): void {
// No queue option => get_option returns false (default from setUp alias).
// wire_wpdb_for_lock() provides $wpdb so Helpers::finalize() ->
// Generation_Lock::release() can run without "Call to member on null".
$this->wire_wpdb_for_lock();
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
$this->assertNotContains(
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
$this->scheduled,
'Empty queue must not reschedule GENERATE'
);
}
/**
* A halted run causes generate tick to abort: queue is cleaned up and
* GENERATE is not rescheduled (Article_Worker would not be called again).
*/
public function test_generate_tick_aborts_on_halted_run_without_rescheduling(): void {
$run_id = 'halted-run-uuid';
$this->options['prautoblogger_article_queue'] = array(
'run_id' => $run_id,
'ideas' => array( $this->make_idea() ),
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
);
$wpdb = $this->create_mock_wpdb();
$wpdb->options = 'wp_options';
$GLOBALS['wpdb'] = $wpdb;
$wpdb->method( 'get_row' )->willReturn( array( 'status' => 'halted', 'ceiling_usd' => '0' ) );
$wpdb->method( 'query' )->willReturn( 1 );
$wpdb->method( 'get_var' )->willReturn( null );
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
$this->assertArrayNotHasKey(
'prautoblogger_article_queue',
$this->options,
'Halted run must trigger queue cleanup'
);
$this->assertNotContains(
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
$this->scheduled,
'Halted run must not reschedule GENERATE'
);
}
/**
* With 2 ideas in queue, first generate tick pops one, reschedules GENERATE,
* and leaves 1 idea remaining in the persisted queue.
*/
public function test_generate_tick_pops_one_idea_and_reschedules_when_more_remain(): void {
$run_id = 'multi-idea-run-uuid';
$this->options['prautoblogger_article_queue'] = array(
'run_id' => $run_id,
'ideas' => array( $this->make_idea( 'Idea A' ), $this->make_idea( 'Idea B' ) ),
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
);
$this->options['prautoblogger_checkpoint_run_id'] = $run_id;
$wpdb = $this->create_mock_wpdb();
$wpdb->options = 'wp_options';
$GLOBALS['wpdb'] = $wpdb;
$wpdb->method( 'get_row' )->willReturn( array( 'status' => 'running', 'ceiling_usd' => '0' ) );
$wpdb->method( 'query' )->willReturn( 1 );
$wpdb->method( 'get_var' )->willReturn( null );
// Provide stub Article_Worker so CI doesn't need real LLM dependencies.
if ( ! class_exists( 'PRAutoBlogger_Article_Worker', false ) ) {
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
eval( 'class PRAutoBlogger_Article_Worker {
public function __construct($ct){}
public function generate($idea){ return ["generated"=>1,"published"=>1,"rejected"=>0,"cost"=>0.01]; }
}' );
}
if ( ! class_exists( 'PRAutoBlogger_Post_Assembler', false ) ) {
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
eval( 'class PRAutoBlogger_Post_Assembler { public static function amortize_research_costs($r){} }' );
}
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
$this->assertContains(
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
$this->scheduled,
'More ideas remaining: GENERATE must be rescheduled'
);
if ( isset( $this->options['prautoblogger_article_queue'] ) ) {
$remaining = $this->options['prautoblogger_article_queue']['ideas'] ?? array();
$this->assertCount( 1, $remaining, 'Queue must have exactly 1 idea left after first tick' );
}
}
}