Files
prautoblogger/tests/unit/Core/ResearchFanoutTest.php
peptiderepo 344412c4e7 feat(phase2b): P2b.1 — Research_Fanout + Research_Judge (curate stage) (#178, v0.28.0)
Additive: parallel research fan-out (curl_multi, quorum, cost-reserve-before-dispatch) + curate judge (dedup/score/run_sources). Live Economy path untouched; Authority activates at P2b.4. QA APPROVE-WITH-P2 685c40b; CI green (PHPUnit verified on VPS runner).

Agent-Session: prab-phase2b-1-merge
2026-06-24 07:40:23 +07:00

281 lines
10 KiB
PHP

<?php
/**
* Tests for PRAutoBlogger_Research_Fanout.
*
* Covers: quorum logic, batch cost-reserve wiring, partial-failure handling.
* The curl_multi execution is delegated to PRAutoBlogger_Research_Batch;
* these tests inject a stub batch to isolate the fanout orchestration logic.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ResearchFanoutTest extends BaseTestCase {
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
private $wpdb;
protected function setUp(): void {
parent::setUp();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $this->wpdb;
$this->wpdb->method( 'prepare' )->willReturnCallback(
static function ( $sql, ...$args ) {
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
}
);
\PRAutoBlogger_Run_Context::clear();
\PRAutoBlogger_Run_State::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
$this->stub_get_option(
array(
'prautoblogger_per_run_cost_ceiling_usd' => 0.50,
'prautoblogger_research_agent_count' => 3,
'prautoblogger_research_model' => 'google/gemini-2.5-flash-lite',
'prautoblogger_log_level' => 'error',
)
);
Functions\when( 'wp_json_encode' )->alias( 'json_encode' );
Functions\when( 'sanitize_url' )->alias( static fn( $u ) => $u );
Functions\when( 'sanitize_text_field' )->alias( 'trim' );
Functions\when( 'sanitize_textarea_field' )->alias( 'trim' );
}
protected function tearDown(): void {
\PRAutoBlogger_Run_Context::clear();
\PRAutoBlogger_Run_State::flush_cache();
\PRAutoBlogger_Run_Stage_State::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
// ── Quorum logic ────────────────────────────────────────────────────
/**
* With N=3, quorum = ceil(3/2)+1 = 3. All 3 agents succeed → results returned.
*/
public function test_all_agents_succeed_meets_quorum(): void {
$batch = $this->make_stub_batch( $this->make_valid_raw_results( 3 ) );
$fanout = $this->make_fanout( $batch );
$results = $fanout->dispatch( 'run-1', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
$this->assertCount( 3, $results );
foreach ( $results as $r ) {
$this->assertArrayHasKey( 'sources', $r );
$this->assertArrayHasKey( 'agent_role', $r );
}
}
/**
* N=3, quorum=3. Only 2 agents succeed (one fails) → quorum NOT met → empty.
*/
public function test_partial_failure_below_quorum_returns_empty(): void {
$raw = $this->make_valid_raw_results( 3 );
$raw[2] = array( 'error' => 'timeout' ); // Slot 2 fails.
$batch = $this->make_stub_batch( $raw );
$fanout = $this->make_fanout( $batch );
$results = $fanout->dispatch( 'run-2', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
$this->assertEmpty( $results, 'Quorum not met: should return empty array.' );
}
/**
* N=3, quorum=3. Agent returns invalid JSON schema → excluded → empty.
*/
public function test_invalid_schema_agent_excluded(): void {
$raw = $this->make_valid_raw_results( 3 );
$raw[0] = array( 'content' => '{"bad":"schema"}', 'actual_cost' => 0.001 );
$raw[1] = array( 'error' => 'bad' );
$raw[2] = array( 'error' => 'bad' );
$batch = $this->make_stub_batch( $raw );
$fanout = $this->make_fanout( $batch );
$results = $fanout->dispatch( 'run-3', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
$this->assertEmpty( $results );
}
/**
* N=5, quorum = ceil(5/2)+1 = 4. 4 agents succeed, 1 fails → quorum met.
*/
public function test_quorum_formula_for_n_5(): void {
$this->stub_get_option(
array(
'prautoblogger_per_run_cost_ceiling_usd' => 0.50,
'prautoblogger_research_agent_count' => 5,
'prautoblogger_research_model' => 'google/gemini-2.5-flash-lite',
'prautoblogger_log_level' => 'error',
)
);
$raw = $this->make_valid_raw_results( 5 );
$raw[4] = array( 'error' => 'failed' ); // 4 succeed, 1 fails: 4 >= 4 → quorum met.
$batch = $this->make_stub_batch( $raw );
$fanout = $this->make_fanout( $batch );
$results = $fanout->dispatch( 'run-4', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
$this->assertCount( 4, $results );
}
// ── Cost reserve wiring ─────────────────────────────────────────────
/**
* Cost governor open_amount_reservation is NOT called when there is
* no active run context (ungoverned behavior unchanged).
*/
public function test_no_run_context_ungoverned(): void {
// No run context set → Cost_Governor::open_amount_reservation returns null.
$batch = $this->make_stub_batch( $this->make_valid_raw_results( 3 ) );
$fanout = $this->make_fanout( $batch );
// Should not throw — just proceeds ungoverned.
$results = $fanout->dispatch( 'run-5', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
$this->assertCount( 3, $results );
}
/**
* When open_amount_reservation() throws PRAutoBlogger_Cost_Ceiling_Exception,
* batch->execute() must NEVER be called — cost governance aborts before dispatch.
*/
public function test_ceiling_breach_exception_aborts_before_dispatch(): void {
$batch = $this->getMockBuilder( \PRAutoBlogger_Research_Batch::class )
->disableOriginalConstructor()
->onlyMethods( array( 'execute' ) )
->getMock();
// PHPUnit enforces this: execute() must NEVER be called when ceiling is breached.
$batch->expects( $this->never() )->method( 'execute' );
// ceiling_usd=0.0: any reservation attempt breaches → UPDATE affects 0 rows → exception.
// get_var must return the table name so Run_State::is_available() returns true;
// without this, open_amount_reservation() short-circuits (returns null, no throw).
$this->wpdb->method( 'get_var' )->willReturnCallback(
static function ( $sql ) {
// Run_State::table_name() = 'wp_prautoblogger_runs' (not run_states).
// is_available() does SHOW TABLES LIKE %s where %s = wp_prautoblogger_runs.
if ( false !== strpos( (string) $sql, 'prautoblogger_runs' ) ) {
return 'wp_prautoblogger_runs';
}
return null;
}
);
// ceiling_usd must be > 0; 0.0 means 'ceiling disabled' (governor returns null, no throw).
// Set ceiling=0.01, already reserved=0.01 (at limit) so any additional reserve
// fails — query() returns 0 rows affected → on_breach() → throws.
// get_row() is called with ARRAY_A mode; get_run() does is_array() check.
// Return an array (not an object) so get_run() returns the row instead of null.
// ceiling=0.01, reserved=0.01 → at limit; any further reserve fails (query→0).
$this->wpdb->method( 'get_row' )->willReturn( array(
'run_id' => 'run-ceiling',
'ceiling_usd' => 0.01,
'reserved_usd' => 0.01,
'settled_usd' => 0.0,
'status' => 'running',
) );
$this->wpdb->method( 'query' )->willReturn( 0 );
\PRAutoBlogger_Run_Context::set_run_id( 'run-ceiling' );
$fanout = $this->make_fanout( $batch );
$this->expectException( \PRAutoBlogger_Cost_Ceiling_Exception::class );
$fanout->dispatch( 'run-ceiling', 'idea:abc', $this->make_idea(), $this->make_cost_tracker() );
}
// ── Helpers ─────────────────────────────────────────────────────────
/**
* Build a stub Research_Batch that returns pre-canned raw results.
*
* @param array $raw_results
* @return \PHPUnit\Framework\MockObject\MockObject
*/
private function make_stub_batch( array $raw_results ) {
$stub = $this->getMockBuilder( \PRAutoBlogger_Research_Batch::class )
->disableOriginalConstructor()
->onlyMethods( array( 'execute' ) )
->getMock();
$stub->method( 'execute' )->willReturn( $raw_results );
return $stub;
}
/**
* Build a fanout with an injected batch stub and a stubbed cost tracker.
*
* @param object $batch
* @return \PRAutoBlogger_Research_Fanout
*/
private function make_fanout( $batch ): \PRAutoBlogger_Research_Fanout {
// NOTE: do NOT re-stub get_option here. setUp() and individual tests call
// stub_get_option() with the correct agent_count for that test. Re-stubbing
// here with hardcoded agent_count=3 silently overrides the test's value and
// caused test_quorum_formula_for_n_5 to dispatch 3 agents against 5 results.
return new \PRAutoBlogger_Research_Fanout( $batch );
}
/**
* Generate N valid raw results (the shape Research_Batch returns).
*
* @param int $n
* @return array
*/
private function make_valid_raw_results( int $n ): array {
$results = array();
for ( $i = 0; $i < $n; $i++ ) {
$results[] = array(
'content' => json_encode( array(
'sources' => array(
array( 'url' => "https://example.com/source-$i", 'title' => "Source $i", 'excerpt' => 'Test', 'relevance' => 0.8 ),
),
) ),
'model' => 'google/gemini-2.5-flash-lite',
'prompt_tokens' => 100,
'completion_tokens' => 200,
'actual_cost' => 0.001,
);
}
return $results;
}
/**
* @return \PRAutoBlogger_Article_Idea
*/
private function make_idea(): \PRAutoBlogger_Article_Idea {
return new \PRAutoBlogger_Article_Idea( array(
'topic' => 'BPC-157 peptide research',
'article_type' => 'guide',
'suggested_title' => 'BPC-157: Research Overview',
'summary' => 'Test.',
'score' => 0.9,
'source_ids' => array(),
'key_points' => array(),
'target_keywords' => array(),
) );
}
/**
* @return \PRAutoBlogger_Cost_Tracker
*/
private function make_cost_tracker(): \PRAutoBlogger_Cost_Tracker {
$ct = $this->getMockBuilder( \PRAutoBlogger_Cost_Tracker::class )
->disableOriginalConstructor()
->onlyMethods( array( 'log_api_call', 'is_budget_exceeded' ) )
->getMock();
$ct->method( 'log_api_call' ); // void: no return value
$ct->method( 'is_budget_exceeded' )->willReturn( false );
return $ct;
}
}