Files
prautoblogger/tests/unit/Core/ImagePromptBuilderTest.php
peptiderepo 0505e50e85 feat(images): expose rewriter system prompt as admin setting
CEO feedback: every generated image is anchoring on the cartoon
peptide-with-arms-and-legs character because the hardcoded rewriter
system prompt both lists "anthropomorphized molecules" first in its
primer and ships a few-shot example that literally shows one. The ask
is to give CEO the lever without a code change.

Adds `prautoblogger_image_prompt_instructions` (textarea) in the
Images section. Default value is the existing REWRITER_SYSTEM_PROMPT
constant verbatim, so behaviour is unchanged on upgrade until someone
edits it. `Image_Prompt_Builder::rewrite_via_llm()` reads the option
at call time via a new `resolve_system_prompt()` helper and falls back
to the constant if the option is blank or whitespace-only, so a saved
empty value cannot brick image generation.

Const visibility flipped from `private` to `public` so the settings
field can reference it directly as the field default.

Tests:

- `test_rewrite_uses_setting_when_present` — option set, assert the
  LLM's system message equals the option content.
- `test_rewrite_falls_back_to_default_when_setting_empty` — option
  whitespace, assert the LLM's system message equals the constant.

Uninstall: new option covered by the existing prautoblogger_% LIKE
sweep in uninstall.php.

Cost impact: none by default. Future CEO edits will change prompt
length; current default is ~180 tokens, budget impact linear from
there.

Part of the v0.8.0 image bundle (thread image-mime-bug, message 03).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:11:15 +07:00

239 lines
7.4 KiB
PHP

<?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 trim( strip_tags( $str ) );
} );
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [];
return $options[ $key ] ?? $default;
} );
}
/**
* Inject a mock OpenRouter provider into the Prompt_Builder via
* reflection so the rewrite path can be exercised without a real HTTP
* call. Used by the system-prompt override tests below.
*/
private function inject_llm(
\PRAutoBlogger_Image_Prompt_Builder $builder,
$mock_llm
): void {
$ref = new \ReflectionClass( \PRAutoBlogger_Image_Prompt_Builder::class );
$prop = $ref->getProperty( 'llm' );
$prop->setAccessible( true );
$prop->setValue( $builder, $mock_llm );
}
/**
* 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 ) );
}
/**
* When `prautoblogger_image_prompt_instructions` is non-empty, the
* rewriter LLM must receive that content as its system message —
* NOT the hardcoded REWRITER_SYSTEM_PROMPT constant.
*/
public function test_rewrite_uses_setting_when_present(): void {
$custom_system = 'CUSTOM-OVERRIDE: tell the LLM a very different thing.';
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) use ( $custom_system ) {
if ( 'prautoblogger_image_prompt_instructions' === $key ) {
return $custom_system;
}
if ( 'prautoblogger_image_style_suffix' === $key ) {
return 'Style: x';
}
return $default;
} );
$captured_messages = [];
$mock_llm = $this->createMock( \PRAutoBlogger_OpenRouter_Provider::class );
$mock_llm->method( 'send_chat_completion' )->willReturnCallback(
function ( $messages ) use ( &$captured_messages ) {
$captured_messages = $messages;
return [
'content' => "A scene.\n\n\"A caption.\"",
'prompt_tokens' => 10,
'completion_tokens' => 10,
];
}
);
$mock_llm->method( 'estimate_cost' )->willReturn( 0.0 );
// Stub $wpdb so Cost_Tracker::log_api_call inside rewrite_via_llm
// can insert without blowing up.
$mock_wpdb = $this->create_mock_wpdb();
$mock_wpdb->insert_id = 1;
$mock_wpdb->method( 'insert' )->willReturn( 1 );
$GLOBALS['wpdb'] = $mock_wpdb;
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
$this->inject_llm( $builder, $mock_llm );
$builder->build_article_prompt( [ 'post_title' => 'Test', 'post_content' => '<p>Body.</p>' ] );
unset( $GLOBALS['wpdb'] );
$this->assertNotEmpty( $captured_messages, 'Rewriter LLM was not called.' );
$this->assertSame( 'system', $captured_messages[0]['role'] ?? '' );
$this->assertSame( $custom_system, $captured_messages[0]['content'] ?? '' );
}
/**
* When the option is empty (or whitespace-only), the rewriter must
* fall back to REWRITER_SYSTEM_PROMPT — belt-and-braces so a blank
* save from the admin UI cannot brick image generation.
*/
public function test_rewrite_falls_back_to_default_when_setting_empty(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
if ( 'prautoblogger_image_prompt_instructions' === $key ) {
return ' '; // whitespace — should trigger fallback.
}
if ( 'prautoblogger_image_style_suffix' === $key ) {
return 'Style: x';
}
return $default;
} );
$captured_messages = [];
$mock_llm = $this->createMock( \PRAutoBlogger_OpenRouter_Provider::class );
$mock_llm->method( 'send_chat_completion' )->willReturnCallback(
function ( $messages ) use ( &$captured_messages ) {
$captured_messages = $messages;
return [
'content' => "A scene.\n\n\"A caption.\"",
'prompt_tokens' => 10,
'completion_tokens' => 10,
];
}
);
$mock_llm->method( 'estimate_cost' )->willReturn( 0.0 );
// Stub $wpdb so Cost_Tracker::log_api_call inside rewrite_via_llm
// can insert without blowing up.
$mock_wpdb = $this->create_mock_wpdb();
$mock_wpdb->insert_id = 1;
$mock_wpdb->method( 'insert' )->willReturn( 1 );
$GLOBALS['wpdb'] = $mock_wpdb;
$builder = new \PRAutoBlogger_Image_Prompt_Builder();
$this->inject_llm( $builder, $mock_llm );
$builder->build_article_prompt( [ 'post_title' => 'Test', 'post_content' => '<p>Body.</p>' ] );
unset( $GLOBALS['wpdb'] );
$this->assertSame(
\PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
$captured_messages[0]['content'] ?? ''
);
}
}