feat(images): wire image pipeline into article flow (commit 1b) (#14)
Completes the image + A/B experiment workstream by integrating image generation into the post publishing pipeline. New classes: - class-image-pipeline.php: Orchestrates A/B generation (article-driven Image A → featured; source-driven Image B → post meta). Each image independently fallible; article publishes even if both fail. - class-image-prompt-builder.php: Synthesizes visual prompts from article title+content and Reddit source data. Appends CEO-locked 90s infomercial style suffix. Keeps prompts concise for FLUX.1 quality. - class-image-media-sideloader.php: Downloads image bytes, creates temp file, sideloads via media_handle_sideload(), sets alt text and metadata, cleans up temp files. Changes to existing files: - Publisher.attach_generated_images(): Runs image pipeline post-creation, sets _thumbnail_id (Image A) and _prautoblogger_image_b_id (Image B). Errors logged but don't block post publication. - Settings: prautoblogger_image_enabled (toggle, default off). Tests: - ImagePipelineTest.php: Orchestration, cost tracking, graceful failures. - ImagePromptBuilderTest.php: Prompt generation, length limits. Docs: - ARCHITECTURE.md: New step 6b in data flow, new files in file tree. - CHANGELOG.md: Commit 1a/1b breakdown. Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
149
tests/unit/Core/ImagePipelineTest.php
Normal file
149
tests/unit/Core/ImagePipelineTest.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Pipeline.
|
||||
*
|
||||
* Validates image generation orchestration, including A/B generation,
|
||||
* cost tracking, and graceful failure handling.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ImagePipelineTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Mock WordPress functions.
|
||||
Functions\when( 'wp_generate_uuid4' )->returnArg();
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
static $options = [
|
||||
'prautoblogger_image_enabled' => '1',
|
||||
'prautoblogger_image_style_suffix' => 'Style: test style',
|
||||
];
|
||||
return $options[ $key ] ?? $default;
|
||||
} );
|
||||
Functions\when( 'sanitize_text_field' )->returnArg();
|
||||
Functions\when( 'update_post_meta' )->justReturn( true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test generate_and_attach_images() returns early if disabled.
|
||||
*/
|
||||
public function test_generate_and_attach_images_skipped_if_disabled(): void {
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
static $options = [ 'prautoblogger_image_enabled' => '0' ];
|
||||
return $options[ $key ] ?? $default;
|
||||
} );
|
||||
|
||||
// Mock the logger.
|
||||
$logger = $this->createMock( \PRAutoBlogger_Logger::class );
|
||||
$logger->expects( $this->once() )->method( 'info' );
|
||||
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->expects( $this->never() )->method( 'generate_image' );
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
|
||||
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
|
||||
|
||||
$this->assertArrayHasKey( 'cost_usd', $result );
|
||||
$this->assertArrayHasKey( 'errors', $result );
|
||||
$this->assertEmpty( $result['errors'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test generate_and_attach_images() returns cost and error array structure.
|
||||
*/
|
||||
public function test_generate_and_attach_images_returns_correct_structure(): void {
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||
$provider->method( 'generate_image' )->willReturn( [
|
||||
'bytes' => 'fake_image_data',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'model' => 'flux-1-schnell',
|
||||
'seed' => 123,
|
||||
'cost_usd' => 0.05,
|
||||
'latency_ms' => 2000,
|
||||
] );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||
$cost_tracker->method( 'log_image_generation' )->willReturn( true );
|
||||
|
||||
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||
|
||||
// Mock file writing for sideload.
|
||||
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
|
||||
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
|
||||
|
||||
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
|
||||
|
||||
$this->assertArrayHasKey( 'cost_usd', $result );
|
||||
$this->assertArrayHasKey( 'errors', $result );
|
||||
$this->assertIsFloat( $result['cost_usd'] );
|
||||
$this->assertIsArray( $result['errors'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that cost is accumulated when both images are generated.
|
||||
*/
|
||||
public function test_cost_is_accumulated_for_both_images(): void {
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||
$provider->method( 'generate_image' )->willReturn( [
|
||||
'bytes' => 'fake_image_data',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'model' => 'flux-1-schnell',
|
||||
'cost_usd' => 0.05,
|
||||
'latency_ms' => 2000,
|
||||
] );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||
$cost_tracker->expects( $this->exactly( 2 ) )->method( 'log_image_generation' );
|
||||
|
||||
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||
|
||||
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
|
||||
Functions\when( 'media_handle_sideload' )->justReturn( 42 );
|
||||
|
||||
$result = $pipeline->generate_and_attach_images(
|
||||
1,
|
||||
[ 'post_title' => 'Test' ],
|
||||
[ 'title' => 'Source' ]
|
||||
);
|
||||
|
||||
// Cost should be accumulated from both images.
|
||||
$this->assertGreaterThan( 0.05, $result['cost_usd'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test graceful handling when image generation fails.
|
||||
*/
|
||||
public function test_graceful_failure_when_image_generation_fails(): void {
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||
$provider->method( 'generate_image' )->willThrowException( new \Exception( 'API timeout' ) );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||
|
||||
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker );
|
||||
|
||||
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ] );
|
||||
|
||||
// Should record error but return valid structure.
|
||||
$this->assertIsArray( $result );
|
||||
$this->assertNotEmpty( $result['errors'] );
|
||||
$this->assertStringContainsString( 'API timeout', implode( ' ', $result['errors'] ) );
|
||||
}
|
||||
}
|
||||
122
tests/unit/Core/ImagePromptBuilderTest.php
Normal file
122
tests/unit/Core/ImagePromptBuilderTest.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Prompt_Builder.
|
||||
*
|
||||
* Validates prompt generation from article content and source data,
|
||||
* including style suffix appending and HTML stripping.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ImagePromptBuilderTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// Mock WordPress functions.
|
||||
Functions\when( 'wp_strip_all_tags' )->alias( function ( $str ) {
|
||||
return wp_strip_all_tags( $str );
|
||||
} );
|
||||
Functions\when( 'sanitize_text_field' )->returnArg();
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
static $options = [];
|
||||
return $options[ $key ] ?? $default;
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test build_article_prompt() generates a prompt from article title and content.
|
||||
*/
|
||||
public function test_build_article_prompt_from_content(): void {
|
||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||
|
||||
$article_data = [
|
||||
'post_title' => 'How to Train Your Dragon',
|
||||
'post_content' => '<p>Dragons are fascinating creatures. Learn the secrets.</p>',
|
||||
];
|
||||
|
||||
$prompt = $builder->build_article_prompt( $article_data );
|
||||
|
||||
$this->assertStringContainsString( 'How to Train Your Dragon', $prompt );
|
||||
$this->assertStringContainsString( 'Dragons are fascinating', $prompt );
|
||||
// Style suffix should be appended.
|
||||
$this->assertNotEmpty( $prompt );
|
||||
$this->assertGreaterThan( 50, strlen( $prompt ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test build_article_prompt() handles missing content gracefully.
|
||||
*/
|
||||
public function test_build_article_prompt_with_missing_content(): void {
|
||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||
|
||||
$article_data = [
|
||||
'post_title' => 'Minimal Article',
|
||||
];
|
||||
|
||||
$prompt = $builder->build_article_prompt( $article_data );
|
||||
|
||||
$this->assertStringContainsString( 'Minimal Article', $prompt );
|
||||
$this->assertNotEmpty( $prompt );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test build_source_prompt() generates a prompt from Reddit data.
|
||||
*/
|
||||
public function test_build_source_prompt_from_reddit_data(): void {
|
||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||
|
||||
$source_data = [
|
||||
'title' => 'Help: My Robot Won\'t Stop Dancing',
|
||||
'comments' => [
|
||||
'Have you tried unplugging it?',
|
||||
'Maybe it\'s just happy.',
|
||||
],
|
||||
];
|
||||
|
||||
$prompt = $builder->build_source_prompt( $source_data );
|
||||
|
||||
$this->assertStringContainsString( 'Robot', $prompt );
|
||||
$this->assertStringContainsString( 'Dancing', $prompt );
|
||||
$this->assertNotEmpty( $prompt );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test build_source_prompt() handles missing comments.
|
||||
*/
|
||||
public function test_build_source_prompt_without_comments(): void {
|
||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||
|
||||
$source_data = [
|
||||
'title' => 'Standalone Title',
|
||||
];
|
||||
|
||||
$prompt = $builder->build_source_prompt( $source_data );
|
||||
|
||||
$this->assertStringContainsString( 'Standalone Title', $prompt );
|
||||
$this->assertNotEmpty( $prompt );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that prompts don't exceed reasonable length.
|
||||
*/
|
||||
public function test_prompts_are_reasonably_sized(): void {
|
||||
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
|
||||
|
||||
$long_content = '<p>' . str_repeat( 'a', 5000 ) . '</p>';
|
||||
$article_data = [
|
||||
'post_title' => 'Test',
|
||||
'post_content' => $long_content,
|
||||
];
|
||||
|
||||
$prompt = $builder->build_article_prompt( $article_data );
|
||||
|
||||
// Prompt should be under 500 chars (concept + style suffix).
|
||||
$this->assertLessThan( 500, strlen( $prompt ) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user