feat(M3): dossier edit+rerun UI, F2/F3, run-list badges — v0.20.0
- Dossier_Actions (4 AJAX endpoints, manage_options+nonce): save fork / queue replay / queue rebuild / stage-status poll. Run identity always derived server-side from post meta; the AJAX layer validates + queues ONLY — all mutations happen in the cron handlers (chained-cron, CPO hard constraint). Fork saves lock message structure (count/roles) so every version stays replayable. - Edit panel per editable stage: per-message textareas prefilled from the latest fork or the recorded request_json; explicit fork + downstream-stale + queued-execution copy; spend strip with ceiling warning (filterable fraction, default 0.8). Disabled stages always show WHY (policy reasons; missing-input retention copy). - Stale / human-edited / attempt chips per stage + header badges (M2 slot filled); dossier polls stage status while a re-run is active (board poll interval setting) and re-renders when the run settles. - F2: sidebar Models & Prompts card (per-stage model/pv/role) + Run Spend card. F3: log-only stages (image_a/b, llm_research, ...) render from generation_log + the _prautoblogger_image_role attachment grid. - M2 correctness: dossier is now item-scoped (idea-hash meta) and excludes gen_log rows linked to other posts of a multi-article run. - Run-list visibility (product AC): board cards get a Human-modified chip; Review Queue rows get a stale-stages chip linking to the dossier. Both via single batched queries (no N+1). - v0.20.0 + CHANGELOG + ARCHITECTURE #24 (+ file tree, schema tables, db-version note) in this commit per versioning discipline. Agent-Session: engineer-prab-m3-20260612
This commit is contained in:
120
tests/unit/Admin/BoardHumanModifiedFlagTest.php
Normal file
120
tests/unit/Admin/BoardHumanModifiedFlagTest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for the v0.20.0 board-card human_modified flag (CPO product AC:
|
||||
* human-modified runs are visually distinct at the run-list level).
|
||||
*
|
||||
* Locks: ONE batched query flags all cards (no N+1), unflagged runs and
|
||||
* missing-table sites degrade to false.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class BoardHumanModifiedFlagTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
/**
|
||||
* Number of get_col calls (N+1 guard).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private int $col_queries = 0;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->col_queries = 0;
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
|
||||
$args = $args[0];
|
||||
}
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
static function ( $post_id, $key ) {
|
||||
if ( '_prautoblogger_run_id' === $key ) {
|
||||
return 'run-' . $post_id;
|
||||
}
|
||||
if ( '_prautoblogger_total_cost' === $key ) {
|
||||
return '0.05';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
);
|
||||
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 );
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\WP_Query::$_test_posts_queue = array();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** Build a WP_Post for the queue. */
|
||||
private function make_post( int $id ): \WP_Post {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = $id;
|
||||
$post->post_title = 'Post ' . $id;
|
||||
$post->post_date_gmt = '2026-06-12 00:00:00';
|
||||
return $post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards whose runs carry human_modified rows get the flag; others
|
||||
* stay false; exactly ONE batched query runs for the whole column.
|
||||
*/
|
||||
public function test_cards_flagged_in_one_batched_query(): void {
|
||||
\WP_Query::$_test_posts_queue[] = array( $this->make_post( 1 ), $this->make_post( 2 ), $this->make_post( 3 ) );
|
||||
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
$this->wpdb->method( 'get_col' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
++$this->col_queries;
|
||||
$this->assertStringContainsString( 'human_modified = 1', (string) $sql );
|
||||
return array( 'run-2' ); // Only post 2's run was edited.
|
||||
}
|
||||
);
|
||||
|
||||
$cards = ( new \PRAutoBlogger_Board_Data_Provider() )->get_in_review_cards();
|
||||
|
||||
$this->assertCount( 3, $cards );
|
||||
$this->assertFalse( $cards[0]['human_modified'] );
|
||||
$this->assertTrue( $cards[1]['human_modified'] );
|
||||
$this->assertFalse( $cards[2]['human_modified'] );
|
||||
$this->assertSame( 1, $this->col_queries, 'one batched query, never per-card' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing substrate table: flags default false, zero flag queries.
|
||||
*/
|
||||
public function test_missing_table_degrades_to_false(): void {
|
||||
\WP_Query::$_test_posts_queue[] = array( $this->make_post( 1 ) );
|
||||
|
||||
$this->wpdb->method( 'get_var' )->willReturn( null );
|
||||
$this->wpdb->method( 'get_col' )->willReturnCallback(
|
||||
function () {
|
||||
++$this->col_queries;
|
||||
return array();
|
||||
}
|
||||
);
|
||||
|
||||
$cards = ( new \PRAutoBlogger_Board_Data_Provider() )->get_in_review_cards();
|
||||
|
||||
$this->assertFalse( $cards[0]['human_modified'] );
|
||||
$this->assertSame( 0, $this->col_queries );
|
||||
}
|
||||
}
|
||||
103
tests/unit/Admin/DossierActionsRerunTest.php
Normal file
103
tests/unit/Admin/DossierActionsRerunTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
/**
|
||||
* Dossier_Actions: rerun + status endpoint tests (v0.20.0, M3).
|
||||
*
|
||||
* Locks the chained-cron contract (queue only — ZERO state mutations in
|
||||
* the AJAX layer) and the read-only stage-status poll shape.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class DossierActionsRerunTest extends DossierActionsTestCase {
|
||||
|
||||
/**
|
||||
* Chained-cron contract: rerun_stage only schedules — no run/stage
|
||||
* state is mutated by the AJAX layer, and the response says queued.
|
||||
*/
|
||||
public function test_rerun_stage_queues_without_any_state_mutation(): void {
|
||||
$this->wire_eligible_context();
|
||||
// A saved fork exists for the replay check.
|
||||
$wpdb_get_row_fork = array(
|
||||
'version' => '1',
|
||||
'request_json' => '{"model":"m","messages":[{"role":"user","content":"edited"}]}',
|
||||
);
|
||||
// Rebuild get_row: forks resolve, run row terminal.
|
||||
$_POST = array(
|
||||
'post_id' => '99',
|
||||
'stage' => 'draft',
|
||||
'agent_role' => 'writer',
|
||||
);
|
||||
// Replace the get_row wiring (fork now exists).
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->options = 'wp_options';
|
||||
$wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
|
||||
$args = $args[0];
|
||||
}
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
$wpdb->method( 'get_var' )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) {
|
||||
return ( false !== strpos( (string) $sql, 'stage_inputs' ) ) ? 'wp_prautoblogger_stage_inputs' : 'wp_prautoblogger_runs';
|
||||
}
|
||||
return null; // Lock free.
|
||||
}
|
||||
);
|
||||
$wpdb->method( 'get_row' )->willReturnCallback(
|
||||
static function ( $sql ) use ( $wpdb_get_row_fork ) {
|
||||
if ( false !== strpos( (string) $sql, 'stage_inputs' ) ) {
|
||||
return $wpdb_get_row_fork;
|
||||
}
|
||||
return array(
|
||||
'run_id' => 'run-1',
|
||||
'status' => 'done',
|
||||
);
|
||||
}
|
||||
);
|
||||
$wpdb->method( 'query' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
$this->queries[] = (string) $sql;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
||||
|
||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
||||
$this->dispatch( array( $actions, 'on_rerun_stage' ) );
|
||||
|
||||
$this->assertTrue( $this->json['ok'] );
|
||||
$this->assertTrue( $this->json['data']['queued'] );
|
||||
$this->assertCount( 1, $this->scheduled );
|
||||
$this->assertSame( \PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, $this->scheduled[0]['hook'] );
|
||||
$this->assertSame( array( 'run-1', 99, 'draft', 'writer', 'idea:abc123' ), array_slice( $this->scheduled[0]['args'], 0, 5 ) );
|
||||
foreach ( $this->queries as $sql ) {
|
||||
$this->assertStringNotContainsString( 'UPDATE', $sql, 'the AJAX layer must not mutate state' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* stage_status: read-only poll returns run status + per-stage state
|
||||
* with stale/human_modified flags.
|
||||
*/
|
||||
public function test_stage_status_returns_stage_states(): void {
|
||||
$this->wire_eligible_context();
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
$_POST = array( 'post_id' => '99' );
|
||||
|
||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
||||
$this->dispatch( array( $actions, 'on_stage_status' ) );
|
||||
|
||||
$this->assertTrue( $this->json['ok'] );
|
||||
$this->assertArrayHasKey( 'run_status', $this->json['data'] );
|
||||
$this->assertArrayHasKey( 'stages', $this->json['data'] );
|
||||
}
|
||||
}
|
||||
87
tests/unit/Admin/DossierActionsSaveInputTest.php
Normal file
87
tests/unit/Admin/DossierActionsSaveInputTest.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* Dossier_Actions: save_input endpoint tests (v0.20.0, M3).
|
||||
*
|
||||
* Locks: published-post rejection (guardrail 5 server-side), fork
|
||||
* structure validation (count/roles locked, contents non-empty), and
|
||||
* the merged-fork save (model/options from the base, never the client).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class DossierActionsSaveInputTest extends DossierActionsTestCase {
|
||||
|
||||
/**
|
||||
* Frozen post: save_input rejects server-side with 400 and inserts
|
||||
* nothing (guardrail 5 is not just hidden UI).
|
||||
*/
|
||||
public function test_save_input_rejects_published_post(): void {
|
||||
$this->wire_eligible_context( 'publish' );
|
||||
$_POST = array(
|
||||
'post_id' => '99',
|
||||
'stage' => 'draft',
|
||||
'agent_role' => 'writer',
|
||||
'messages' => '[]',
|
||||
);
|
||||
|
||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
||||
$this->dispatch( array( $actions, 'on_save_input' ) );
|
||||
|
||||
$this->assertFalse( $this->json['ok'] );
|
||||
$this->assertSame( 400, $this->json['status'] );
|
||||
$this->assertCount( 0, $this->inserted );
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure lock: changing a message ROLE (or count, or emptying a
|
||||
* content) is rejected — forks are always replayable chat bodies.
|
||||
*/
|
||||
public function test_save_input_rejects_structure_mismatch(): void {
|
||||
$this->wire_eligible_context();
|
||||
$_POST = array(
|
||||
'post_id' => '99',
|
||||
'stage' => 'draft',
|
||||
'agent_role' => 'writer',
|
||||
// Role flipped user->assistant vs the base body.
|
||||
'messages' => '[{"role":"system","content":"sys"},{"role":"assistant","content":"edited"}]',
|
||||
);
|
||||
|
||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
||||
$this->dispatch( array( $actions, 'on_save_input' ) );
|
||||
|
||||
$this->assertFalse( $this->json['ok'] );
|
||||
$this->assertCount( 0, $this->inserted );
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid edit: fork v1 saved with the edited content merged into the
|
||||
* base body; model/options come from the base, not the client.
|
||||
*/
|
||||
public function test_save_input_saves_merged_fork(): void {
|
||||
$this->wire_eligible_context();
|
||||
$_POST = array(
|
||||
'post_id' => '99',
|
||||
'stage' => 'draft',
|
||||
'agent_role' => 'writer',
|
||||
'messages' => '[{"role":"system","content":"sys"},{"role":"user","content":"EDITED PROMPT"}]',
|
||||
);
|
||||
|
||||
$actions = new \PRAutoBlogger_Dossier_Actions();
|
||||
$this->dispatch( array( $actions, 'on_save_input' ) );
|
||||
|
||||
$this->assertTrue( $this->json['ok'] );
|
||||
$this->assertSame( 1, $this->json['data']['version'] );
|
||||
$this->assertCount( 1, $this->inserted );
|
||||
$row = $this->inserted[0];
|
||||
$this->assertSame( 'human', $row['source'] );
|
||||
$this->assertSame( 'rhys', $row['author'] );
|
||||
$this->assertSame( 'idea:abc123', $row['item_key'] );
|
||||
$body = json_decode( (string) $row['request_json'], true );
|
||||
$this->assertSame( 'EDITED PROMPT', $body['messages'][1]['content'] );
|
||||
$this->assertSame( 'm', $body['model'] ); // From the base, not the client.
|
||||
}
|
||||
}
|
||||
212
tests/unit/Admin/DossierActionsTestCase.php
Normal file
212
tests/unit/Admin/DossierActionsTestCase.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared harness for the Dossier_Actions endpoint tests (v0.20.0, M3).
|
||||
*
|
||||
* Provides: mock wpdb with SQL-pattern wiring, JSON-response capture
|
||||
* (wp_send_json_* throw a sentinel), cron-schedule capture, and the
|
||||
* eligible-context fixture (draft post, terminal run, free lock).
|
||||
* Split across two test classes for the 300-line file cap (the M2
|
||||
* split-precedent applies to test files too).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
abstract class DossierActionsTestCase extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
protected $wpdb;
|
||||
|
||||
/**
|
||||
* Captured JSON response (success/error + payload + status).
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
protected $json = null;
|
||||
|
||||
/**
|
||||
* SQL captured from $wpdb->query().
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $queries = array();
|
||||
|
||||
/**
|
||||
* Rows captured from $wpdb->insert().
|
||||
*
|
||||
* @var array<int, array>
|
||||
*/
|
||||
protected array $inserted = array();
|
||||
|
||||
/**
|
||||
* Cron events captured from wp_schedule_single_event().
|
||||
*
|
||||
* @var array<int, array>
|
||||
*/
|
||||
protected array $scheduled = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->json = null;
|
||||
$this->queries = array();
|
||||
$this->inserted = array();
|
||||
$this->scheduled = array();
|
||||
$_POST = array();
|
||||
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$this->wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
|
||||
$args = $args[0];
|
||||
}
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'query' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
$this->queries[] = (string) $sql;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'insert' )->willReturnCallback(
|
||||
function ( $table, $row ) {
|
||||
$this->inserted[] = (array) $row;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'check_ajax_referer' )->justReturn( true );
|
||||
Functions\when( 'current_user_can' )->justReturn( true );
|
||||
Functions\when( 'absint' )->alias( static function ( $val ) { return abs( (int) $val ); } );
|
||||
Functions\when( 'sanitize_key' )->alias( static function ( $val ) { return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $val ) ); } );
|
||||
Functions\when( 'wp_unslash' )->returnArg();
|
||||
Functions\when( 'wp_schedule_single_event' )->alias(
|
||||
function ( $ts, $hook, $args ) {
|
||||
$this->scheduled[] = array(
|
||||
'hook' => $hook,
|
||||
'args' => $args,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'spawn_cron' )->justReturn( true );
|
||||
Functions\when( 'wp_remote_post' )->justReturn( array() );
|
||||
Functions\when( 'site_url' )->justReturn( 'https://example.com' );
|
||||
Functions\when( 'wp_send_json_success' )->alias(
|
||||
function ( $data = null ) {
|
||||
$this->json = array(
|
||||
'ok' => true,
|
||||
'data' => $data,
|
||||
);
|
||||
throw new \RuntimeException( 'json_sent' );
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_send_json_error' )->alias(
|
||||
function ( $data = null, $status = 0 ) {
|
||||
$this->json = array(
|
||||
'ok' => false,
|
||||
'data' => $data,
|
||||
'status' => $status,
|
||||
);
|
||||
throw new \RuntimeException( 'json_sent' );
|
||||
}
|
||||
);
|
||||
$user = new \stdClass();
|
||||
$user->user_login = 'rhys';
|
||||
Functions\when( 'wp_get_current_user' )->justReturn( $user );
|
||||
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$_POST = array();
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** Run an endpoint, swallowing the wp_send_json sentinel. */
|
||||
protected function dispatch( callable $endpoint ): void {
|
||||
try {
|
||||
$endpoint();
|
||||
} catch ( \RuntimeException $e ) {
|
||||
if ( 'json_sent' !== $e->getMessage() ) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire post meta + a draft post + a terminal run + free lock. */
|
||||
protected function wire_eligible_context( string $post_status = 'draft', string $run_status = 'done' ): void {
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
static function ( $post_id, $key ) {
|
||||
if ( '_prautoblogger_run_id' === $key ) {
|
||||
return 'run-1';
|
||||
}
|
||||
if ( '_prautoblogger_idea_hash' === $key ) {
|
||||
return 'abc123';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
);
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 99;
|
||||
$post->post_status = $post_status;
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
|
||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
||||
static function ( $sql ) use ( $run_status ) {
|
||||
$sql = (string) $sql;
|
||||
if ( false !== strpos( $sql, 'SHOW TABLES' ) ) {
|
||||
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
|
||||
return 'wp_prautoblogger_stage_inputs';
|
||||
}
|
||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
||||
return 'wp_prautoblogger_run_stages';
|
||||
}
|
||||
return 'wp_prautoblogger_runs';
|
||||
}
|
||||
if ( false !== strpos( $sql, 'MAX(version)' ) ) {
|
||||
return null; // First fork version.
|
||||
}
|
||||
return null; // Lock free; no seed.
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_row' )->willReturnCallback(
|
||||
static function ( $sql ) use ( $run_status ) {
|
||||
$sql = (string) $sql;
|
||||
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
|
||||
return null; // No fork yet.
|
||||
}
|
||||
return array(
|
||||
'run_id' => 'run-1',
|
||||
'status' => $run_status,
|
||||
'ceiling_usd' => '0.50',
|
||||
);
|
||||
}
|
||||
);
|
||||
// resolve_base_body falls through to the gen_log query.
|
||||
$this->wpdb->method( 'get_results' )->willReturn(
|
||||
array(
|
||||
array(
|
||||
'id' => '11',
|
||||
'stage' => 'draft',
|
||||
'request_json' => '{"model":"m","messages":[{"role":"system","content":"sys"},{"role":"user","content":"orig"}]}',
|
||||
'response_status' => 'success',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
236
tests/unit/Admin/DossierAssemblerRerunDataTest.php
Normal file
236
tests/unit/Admin/DossierAssemblerRerunDataTest.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for the v0.20.0 dossier view-model additions (M3 + F2/F3).
|
||||
*
|
||||
* Locks: item-scoped stage filtering (multi-article runs no longer
|
||||
* collide), other-post gen_log row exclusion, per-stage rerun panel
|
||||
* data (policy reasons on non-editable stages; editable writer stage
|
||||
* with recorded input), log-only stage exposure (F3) and the spend
|
||||
* strip shape (guardrail 4 visibility).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class DossierAssemblerRerunDataTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$this->wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
|
||||
$args = $args[0];
|
||||
}
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 );
|
||||
Functions\when( 'get_children' )->justReturn( array() );
|
||||
Functions\when( 'wp_get_attachment_image_url' )->justReturn( '' );
|
||||
Functions\when( 'apply_filters' )->returnArg( 2 );
|
||||
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** Build the standard post + meta wiring. */
|
||||
private function wire_post(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 99;
|
||||
$post->post_title = 'Test Article';
|
||||
$post->post_status = 'draft';
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
static function ( $post_id, $key ) {
|
||||
$map = array(
|
||||
'_prautoblogger_run_id' => 'run-1',
|
||||
'_prautoblogger_idea_hash' => 'abc123',
|
||||
'_prautoblogger_editor_verdict' => 'approved',
|
||||
'_prautoblogger_article_type' => 'guide',
|
||||
);
|
||||
return $map[ $key ] ?? '';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Wire wpdb for: tables exist, run row, stage rows, gen_log rows, lock free. */
|
||||
private function wire_data( array $stage_rows, array $log_rows ): void {
|
||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
$sql = (string) $sql;
|
||||
if ( false !== strpos( $sql, 'SHOW TABLES' ) ) {
|
||||
if ( false !== strpos( $sql, 'stage_inputs' ) ) {
|
||||
return 'wp_prautoblogger_stage_inputs';
|
||||
}
|
||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
||||
return 'wp_prautoblogger_run_stages';
|
||||
}
|
||||
return 'wp_prautoblogger_runs';
|
||||
}
|
||||
return null; // Lock free.
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_row' )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
if ( false !== strpos( (string) $sql, 'stage_inputs' ) ) {
|
||||
return null; // No forks saved.
|
||||
}
|
||||
return array(
|
||||
'run_id' => 'run-1',
|
||||
'status' => 'done',
|
||||
'ceiling_usd' => '0.50',
|
||||
'settled_usd' => '0.30',
|
||||
'reserved_usd' => '0.15',
|
||||
);
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_results' )->willReturnCallback(
|
||||
static function ( $sql ) use ( $stage_rows, $log_rows ) {
|
||||
$sql = (string) $sql;
|
||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
||||
return $stage_rows;
|
||||
}
|
||||
if ( false !== strpos( $sql, 'generation_log' ) ) {
|
||||
return $log_rows;
|
||||
}
|
||||
return array(); // run_decisions.
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage query is item-scoped (idea hash -> item_key IN (item, ''))
|
||||
* and gen_log rows belonging to OTHER posts are dropped while
|
||||
* unlinked (post_id NULL/0) rows are kept.
|
||||
*/
|
||||
public function test_item_scoping_and_other_post_log_exclusion(): void {
|
||||
$this->wire_post();
|
||||
$captured_stage_sql = null;
|
||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) {
|
||||
return false !== strpos( (string) $sql, 'stage_inputs' ) ? 'wp_prautoblogger_stage_inputs'
|
||||
: ( false !== strpos( (string) $sql, 'run_stages' ) ? 'wp_prautoblogger_run_stages' : 'wp_prautoblogger_runs' );
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => 'run-1', 'status' => 'done', 'ceiling_usd' => '0' ) );
|
||||
$this->wpdb->method( 'get_results' )->willReturnCallback(
|
||||
static function ( $sql ) use ( &$captured_stage_sql ) {
|
||||
$sql = (string) $sql;
|
||||
if ( false !== strpos( $sql, 'run_stages' ) ) {
|
||||
$captured_stage_sql = $sql;
|
||||
return array();
|
||||
}
|
||||
if ( false !== strpos( $sql, 'generation_log' ) ) {
|
||||
return array(
|
||||
array( 'id' => '1', 'stage' => 'draft', 'post_id' => '99', 'model' => 'm', 'request_json' => null ),
|
||||
array( 'id' => '2', 'stage' => 'draft', 'post_id' => '77', 'model' => 'm', 'request_json' => null ),
|
||||
array( 'id' => '3', 'stage' => 'llm_research', 'post_id' => null, 'model' => 'm', 'request_json' => null ),
|
||||
);
|
||||
}
|
||||
return array();
|
||||
}
|
||||
);
|
||||
|
||||
$dossier = ( new \PRAutoBlogger_Dossier_Data_Assembler() )->assemble( 99 );
|
||||
|
||||
$this->assertSame( 'idea:abc123', $dossier['item_key'] );
|
||||
$this->assertStringContainsString( "item_key IN (%s, '')", (string) $captured_stage_sql );
|
||||
// Post 77's draft row dropped; this post's row + unlinked research row kept.
|
||||
$this->assertCount( 1, $dossier['gen_log_index']['draft'] );
|
||||
$this->assertSame( '1', $dossier['gen_log_index']['draft'][0]['id'] );
|
||||
$this->assertCount( 1, $dossier['gen_log_index']['llm_research'] );
|
||||
// F3: llm_research has no run_stages row -> exposed as log-only.
|
||||
$this->assertContains( 'llm_research', $dossier['log_only_stages'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-stage rerun data: a writer stage with a recorded request is
|
||||
* editable with prefill; review carries a policy reason instead; the
|
||||
* spend strip reflects the ledger row (guardrail 4 visibility).
|
||||
*/
|
||||
public function test_stage_rerun_panel_data_and_spend(): void {
|
||||
$this->wire_post();
|
||||
$stage_rows = array(
|
||||
array(
|
||||
'id' => '1',
|
||||
'run_id' => 'run-1',
|
||||
'stage' => 'draft',
|
||||
'agent_role' => 'writer',
|
||||
'item_key' => 'idea:abc123',
|
||||
'status' => 'done',
|
||||
'attempt' => '1',
|
||||
'cost_usd' => '0.01',
|
||||
'meta_json' => '{"output":"text"}',
|
||||
'stale' => '0',
|
||||
'human_modified' => '1',
|
||||
),
|
||||
array(
|
||||
'id' => '2',
|
||||
'run_id' => 'run-1',
|
||||
'stage' => 'review',
|
||||
'agent_role' => 'editor',
|
||||
'item_key' => 'idea:abc123',
|
||||
'status' => 'done',
|
||||
'attempt' => '1',
|
||||
'cost_usd' => '0.005',
|
||||
'meta_json' => null,
|
||||
'stale' => '1',
|
||||
),
|
||||
);
|
||||
$log_rows = array(
|
||||
array(
|
||||
'id' => '10',
|
||||
'stage' => 'draft',
|
||||
'post_id' => '99',
|
||||
'model' => 'google/gemini-2.5-flash-lite',
|
||||
'request_json' => '{"model":"google/gemini-2.5-flash-lite","messages":[{"role":"user","content":"draft prompt"}]}',
|
||||
'response_status' => 'success',
|
||||
),
|
||||
);
|
||||
$this->wire_data( $stage_rows, $log_rows );
|
||||
|
||||
$dossier = ( new \PRAutoBlogger_Dossier_Data_Assembler() )->assemble( 99 );
|
||||
|
||||
$draft = $dossier['stages']['draft:writer']['rerun'];
|
||||
$this->assertTrue( $draft['editable'] );
|
||||
$this->assertSame( 'draft prompt', $draft['prefill']['messages'][0]['content'] );
|
||||
$this->assertTrue( $draft['human_modified'] );
|
||||
$this->assertTrue( $dossier['human_modified_any'] );
|
||||
|
||||
$review = $dossier['stages']['review:editor']['rerun'];
|
||||
$this->assertFalse( $review['editable'] );
|
||||
$this->assertStringContainsString( 'Re-run from here', $review['edit_reason'] );
|
||||
$this->assertTrue( $review['stale'] );
|
||||
$this->assertTrue( $review['rerun_from'] );
|
||||
$this->assertTrue( $dossier['stale_any'] );
|
||||
|
||||
$this->assertSame( 0.30, $dossier['spend']['settled'] );
|
||||
$this->assertSame( 0.15, $dossier['spend']['reserved'] );
|
||||
$this->assertSame( 0.50, $dossier['spend']['ceiling'] );
|
||||
$this->assertTrue( $dossier['spend']['warn'] ); // 0.45/0.50 = 90% >= 80%.
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,14 @@ class DossierDataAssemblerTest extends BaseTestCase {
|
||||
// Stub PRAutoBlogger_Run_Stage_State::is_available() to return true.
|
||||
// Static method -- stubbed via wpdb table check returning the table name.
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Stage_Input_Store::flush_cache();
|
||||
|
||||
// v0.20.0 additions: image data + eligibility paths.
|
||||
Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 );
|
||||
Functions\when( 'get_children' )->justReturn( array() );
|
||||
Functions\when( 'wp_get_attachment_image_url' )->justReturn( '' );
|
||||
Functions\when( 'apply_filters' )->returnArg( 2 );
|
||||
|
||||
// table_name returns wp_prautoblogger_run_stages.
|
||||
$this->wpdb->prefix = 'wp_';
|
||||
|
||||
@@ -51,6 +51,11 @@ abstract class DossierMenuRegistrationTestCase extends BaseTestCase {
|
||||
$this->captured_menu_slug = null;
|
||||
$this->registration_hookname = null;
|
||||
|
||||
// v0.20.0: the dossier enqueue path localizes the edit/re-run config.
|
||||
Functions\when( 'wp_localize_script' )->justReturn( true );
|
||||
Functions\when( 'wp_create_nonce' )->justReturn( 'test-nonce' );
|
||||
Functions\when( 'absint' )->alias( static function ( $val ) { return abs( (int) $val ); } );
|
||||
|
||||
// Spy stub: tracks call order, captures slugs, returns a hookname that
|
||||
// reflects how WordPress actually resolves it for known parents.
|
||||
Functions\when( 'add_menu_page' )->alias(
|
||||
|
||||
Reference in New Issue
Block a user