fix(phase2b): Authority hold-on-quorum-miss no longer crashes + pipeline_mode label (#184, v0.32.2)
Canary-found P1: hold with empty content (research quorum miss) recorded a HOLD decision without calling Publisher (no more empty-content RuntimeException → run held, not failed). Content-present holds unchanged. Authority posts now labelled pipeline_mode=authority. QA APPROVE f759d75; CI green (phpcs+phpunit VPS-verified). Agent-Session: prab-phase2b-holdfix-merge
This commit is contained in:
@@ -323,6 +323,138 @@ class AuthorityPipelineTest extends BaseTestCase {
|
||||
$this->assertArrayHasKey( "status", $result );
|
||||
}
|
||||
|
||||
|
||||
// -- P1: Quorum-miss hold (no content) --------------------------------
|
||||
|
||||
/**
|
||||
* When fanout returns empty (quorum miss), the pipeline must record a
|
||||
* HOLD decision row and return status='held-quorum'. No post must be
|
||||
* created (wp_insert_post must NOT be called), and no exception must
|
||||
* be thrown.
|
||||
*
|
||||
* Regression: before v0.32.2, hold_as_draft('') called Publisher which
|
||||
* threw RuntimeException, causing the run to land as 'failed' instead.
|
||||
*/
|
||||
public function test_quorum_miss_hold_records_decision_no_post_created(): void {
|
||||
// Fanout returns empty -- quorum miss.
|
||||
$fanout = $this->make_fanout_stub( array() );
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout );
|
||||
|
||||
// wp_insert_post must never be called on a quorum-miss hold.
|
||||
Functions\expect( "wp_insert_post" )->never();
|
||||
|
||||
$result = $pipeline->run( "run-quorum", $this->make_idea(), $tracker );
|
||||
|
||||
$this->assertSame( "held-quorum", $result["status"] );
|
||||
$this->assertSame( 0, $result["published"] );
|
||||
$this->assertEmpty( $this->published_posts );
|
||||
|
||||
// A run_decisions row must be written for the hold.
|
||||
$hold_decisions = array_filter(
|
||||
$this->decision_rows,
|
||||
static fn( $row ) => "quorum-miss" === $row["verdict"]
|
||||
);
|
||||
$this->assertNotEmpty( $hold_decisions, "Expected a quorum-miss decision row in run_decisions" );
|
||||
}
|
||||
|
||||
/**
|
||||
* When fanout returns empty (quorum miss), the run state must be
|
||||
* 'held-quorum', not 'failed'. The P1 bug caused an uncaught
|
||||
* RuntimeException which Article_Worker caught and marked 'failed'.
|
||||
*/
|
||||
public function test_quorum_miss_does_not_throw(): void {
|
||||
$fanout = $this->make_fanout_stub( array() );
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout );
|
||||
|
||||
// No exception must propagate out of run().
|
||||
$exception_thrown = false;
|
||||
try {
|
||||
$result = $pipeline->run( "run-nothrow", $this->make_idea(), $tracker );
|
||||
} catch ( \Throwable $e ) {
|
||||
$exception_thrown = true;
|
||||
}
|
||||
|
||||
$this->assertFalse( $exception_thrown, "run() must not throw on quorum miss (was RuntimeException before v0.32.2)" );
|
||||
}
|
||||
|
||||
// -- P1: Hold WITH content (editorial escalation) ---------------------
|
||||
|
||||
/**
|
||||
* When the editorial loop escalates with content present, hold_as_draft
|
||||
* must save a draft post (wp_insert_post called once) and suppress
|
||||
* imagery. Verifies the content-present path is unchanged by the fix.
|
||||
*/
|
||||
public function test_editorial_escalation_hold_saves_draft_with_content(): void {
|
||||
$sources = array( array( "url" => "https://a.com", "title" => "A", "quality_score" => 0.9 ) );
|
||||
$fanout = $this->make_fanout_stub( $this->make_fanout_results( $sources ) );
|
||||
$judge = $this->make_judge_stub( $sources );
|
||||
$loop = $this->make_editorial_loop_stub( "<p>escalated draft content</p>", true );
|
||||
$generator = $this->make_generator_stub( "<p>draft content</p>" );
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout, $judge, $loop, $generator );
|
||||
|
||||
$posts_inserted = 0;
|
||||
$original_when = null;
|
||||
Functions\when( "wp_insert_post" )->alias(
|
||||
function ( $args ) use ( &$posts_inserted ) {
|
||||
++$posts_inserted;
|
||||
return 55;
|
||||
}
|
||||
);
|
||||
|
||||
$result = $pipeline->run( "run-escalated", $this->make_idea(), $tracker );
|
||||
|
||||
$this->assertSame( "held-escalated", $result["status"] );
|
||||
$this->assertGreaterThanOrEqual( 1, $posts_inserted, "wp_insert_post must be called for escalated hold with content" );
|
||||
$this->assertArrayHasKey( "_prautoblogger_imagery_suppressed", $this->meta_written );
|
||||
$this->assertSame( "1", $this->meta_written["_prautoblogger_imagery_suppressed"] );
|
||||
}
|
||||
|
||||
// -- P2: pipeline_mode label ------------------------------------------
|
||||
|
||||
/**
|
||||
* Authority published posts must carry _prautoblogger_pipeline_mode='authority'.
|
||||
* The Economy single-pass label is validated separately in PublisherTest.
|
||||
*/
|
||||
public function test_authority_published_post_gets_authority_pipeline_mode(): void {
|
||||
$this->stub_get_option( array(
|
||||
"prautoblogger_log_level" => "error",
|
||||
"prautoblogger_auto_publish" => "0",
|
||||
"prautoblogger_citation_score_threshold" => 0.0,
|
||||
"prautoblogger_writing_pipeline" => "single_pass",
|
||||
"prautoblogger_writing_model" => "openai/gpt-4o-mini",
|
||||
"prautoblogger_default_author" => 1,
|
||||
) );
|
||||
|
||||
$sources = array( array( "url" => "https://a.com", "title" => "A", "quality_score" => 0.8 ) );
|
||||
$fanout = $this->make_fanout_stub( $this->make_fanout_results( $sources ) );
|
||||
$judge = $this->make_judge_stub( $sources );
|
||||
$loop = $this->make_editorial_loop_stub( "<p>approved content</p>", false );
|
||||
$generator = $this->make_generator_stub( "<p>draft content</p>" );
|
||||
$tracker = $this->make_cost_tracker_stub();
|
||||
$pipeline = new \PRAutoBlogger_Authority_Pipeline( $tracker, $fanout, $judge, $loop, $generator );
|
||||
|
||||
$captured_meta = null;
|
||||
Functions\when( "wp_insert_post" )->alias(
|
||||
function ( $args ) use ( &$captured_meta ) {
|
||||
$captured_meta = $args["meta_input"] ?? array();
|
||||
return 66;
|
||||
}
|
||||
);
|
||||
|
||||
$result = $pipeline->run( "run-pmode", $this->make_idea(), $tracker );
|
||||
|
||||
$this->assertNotNull( $captured_meta, "wp_insert_post must have been called" );
|
||||
$this->assertArrayHasKey( "_prautoblogger_pipeline_mode", $captured_meta );
|
||||
$this->assertSame(
|
||||
"authority",
|
||||
$captured_meta["_prautoblogger_pipeline_mode"],
|
||||
"Authority pipeline posts must carry pipeline_mode=authority, not single_pass"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Helpers ----------------------------------------------------------
|
||||
|
||||
private function make_idea(): \PRAutoBlogger_Article_Idea {
|
||||
|
||||
@@ -203,4 +203,65 @@ class PublisherTest extends BaseTestCase {
|
||||
|
||||
$publisher->publish( "<p> \n\t </p>", $this->idea, $this->review );
|
||||
}
|
||||
}
|
||||
/**
|
||||
* P2 fix (v0.32.2): Publisher::publish() called without an explicit
|
||||
* pipeline_mode must record the option value (Economy / single-pass
|
||||
* path). Verifies the fallback behaviour is unchanged.
|
||||
*/
|
||||
public function test_publish_pipeline_mode_defaults_to_option_value(): void {
|
||||
$this->stub_get_option( [
|
||||
'prautoblogger_writing_pipeline' => 'single_pass',
|
||||
'prautoblogger_writing_model' => 'openai/gpt-4o-mini',
|
||||
'prautoblogger_default_author' => 1,
|
||||
] );
|
||||
|
||||
$captured_meta = null;
|
||||
Functions\when( 'wp_insert_post' )->alias( function ( $args ) use ( &$captured_meta ) {
|
||||
$captured_meta = $args['meta_input'] ?? [];
|
||||
return 99;
|
||||
} );
|
||||
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->method( 'query' )->willReturn( 0 );
|
||||
$wpdb->method( 'prepare' )->willReturn( '' );
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
|
||||
$publisher = new \PRAutoBlogger_Publisher();
|
||||
$publisher->publish( '<p>Content</p>', $this->idea, $this->review, 'run-economy' );
|
||||
|
||||
$this->assertArrayHasKey( '_prautoblogger_pipeline_mode', $captured_meta );
|
||||
$this->assertSame( 'single_pass', $captured_meta['_prautoblogger_pipeline_mode'],
|
||||
'Economy path (no explicit pipeline_mode) must use the option value' );
|
||||
}
|
||||
|
||||
/**
|
||||
* P2 fix (v0.32.2): when an explicit pipeline_mode is passed (Authority
|
||||
* path), it overrides the option value and is stored as post meta.
|
||||
*/
|
||||
public function test_publish_explicit_pipeline_mode_overrides_option(): void {
|
||||
$this->stub_get_option( [
|
||||
'prautoblogger_writing_pipeline' => 'single_pass',
|
||||
'prautoblogger_writing_model' => 'openai/gpt-4o-mini',
|
||||
'prautoblogger_default_author' => 1,
|
||||
] );
|
||||
|
||||
$captured_meta = null;
|
||||
Functions\when( 'wp_insert_post' )->alias( function ( $args ) use ( &$captured_meta ) {
|
||||
$captured_meta = $args['meta_input'] ?? [];
|
||||
return 100;
|
||||
} );
|
||||
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->method( 'query' )->willReturn( 0 );
|
||||
$wpdb->method( 'prepare' )->willReturn( '' );
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
|
||||
$publisher = new \PRAutoBlogger_Publisher();
|
||||
$publisher->publish( '<p>Content</p>', $this->idea, $this->review, 'run-authority', null, 'authority' );
|
||||
|
||||
$this->assertArrayHasKey( '_prautoblogger_pipeline_mode', $captured_meta );
|
||||
$this->assertSame( 'authority', $captured_meta['_prautoblogger_pipeline_mode'],
|
||||
'Explicit pipeline_mode must override the option (Authority path)' );
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user