From 3b6a302f954a0d9b710764a32c6e2d1b2dfe27f6 Mon Sep 17 00:00:00 2001 From: peptiderepo <273788462+peptiderepo@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:21:52 +0700 Subject: [PATCH] =?UTF-8?q?fix(maintenance):=20v0.19.4=20=E2=80=94=20compo?= =?UTF-8?q?ser=20GD-rung=20tests,=20layout-key=20cleanup,=20carried=20P3s?= =?UTF-8?q?=20(#160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA: APPROVE-WITH-P2-NOTES @ c0929be (qa-reviews/prautoblogger/2026-06-12-c0929be.md) CEO greenlight: 2026-06-12 in chat. Agent-Session: cto-maint-v0194-merge-20260612 --- CHANGELOG.md | 35 +++ includes/core/class-image-composer-layout.php | 6 +- includes/core/class-post-assembler.php | 8 +- .../class-runware-catalog-fetcher.php | 7 +- prautoblogger.php | 4 +- tests/unit/Core/ImageComposerEditorTest.php | 246 ++++++++++++++++++ tests/unit/Core/ImageComposerTest.php | 39 +++ tests/unit/Core/ResearchReaperTest.php | 2 + 8 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 tests/unit/Core/ImageComposerEditorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2728809..5bf4fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project uses [Semantic Versioning](https://semver.org/). +## [0.19.4] - 2026-06-12 + +### Added +- **GD-rung unit tests.** New `ImageComposerEditorTest.php` (7 tests) covering + `PRAutoBlogger_Image_Composer_Editor`: `wp_get_image_editor` WP_Error, resize WP_Error, + upscale-guard size mismatch, save WP_Error, zero-width target geometry guard, missing-role + guard, and successful OG resize happy path. Orchestrator ladder test + `test_gd_rung_emits_passthrough_featured_and_single_warning` added to `ImageComposerTest.php`, + asserting the Imagick renderer is never called on the `gd` rung and pass-through featured is + always first. + (Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P2-1.) + +### Fixed +- **Dead layout keys removed.** `caption_margin_right` (og) and `caption_side_padding` (square) + were defined in `Layout::defaults()` and exposed via the `prautoblogger_image_compose_layout` + filter but never consumed by the Imagick renderer. The og renderer positions text from + `logo_x + logo_w + caption_gap` (left-anchor); the square renderer uses centered `x` alignment. + Right-edge overflow is enforced via `caption_chars_per_line` on both variants. Keys removed; + filter users tuning these values had no effect and received no error. + (Source: QA verdict `Peptide Repo CTO/qa-reviews/prautoblogger/2026-06-11-6f76df1.md` P3-3.) +- **Amortized research row now carries audit fields.** `Post_Assembler::amortize_research_costs()` + previously inserted per-article `llm_research` copies without `prompt_version` or `agent_role`, + leaving those columns NULL/empty while the original (correctly stamped) row was deleted. + SELECT now includes both columns; INSERT carries them forward from the original row. + (Source: phase-1 thread seq 49, seq 50 carried-list: "amortized llm_research rows pv=null/role + empty" — `convo/prautoblogger/threads/2026-06-pipeline-v2-phase1/`.) +- **`fetch_page()` error body included in exception.** Non-200 responses from the Runware + modelSearch API now include up to 200 chars of the response body in the `RuntimeException` + message, making diagnosis from logs actionable without re-reproducing the request. + (Source: phase-1 thread seq 50 carried-list: "`fetch_page()` error-body logging".) + +### Notes +- cto-review.yml isinstance guard (handoff item 5): the fix (`c.get("severity", "UNKNOWN")`) + is already applied at HEAD (line 454 of the workflow). No changes made. + ## [0.19.3] - 2026-06-12 ### Fixed diff --git a/includes/core/class-image-composer-layout.php b/includes/core/class-image-composer-layout.php index 31a9f2e..0b0c657 100644 --- a/includes/core/class-image-composer-layout.php +++ b/includes/core/class-image-composer-layout.php @@ -38,6 +38,10 @@ class PRAutoBlogger_Image_Composer_Layout { * plugin; sizes are px. Values were tuned against rendered samples (see * PR #thread 2026-06-image-composer assets). * + * Note: 'caption_margin_right' (og) and 'caption_side_padding' (square) were removed + * in v0.19.4 — the Imagick renderer enforces right-edge geometry via caption_chars_per_line + * and centered x positioning respectively, so those keys had no consumer. + * * @return array> Keyed by role: featured, og, square. */ public static function defaults(): array { @@ -60,7 +64,6 @@ class PRAutoBlogger_Image_Composer_Layout { 'caption_size' => 32, 'caption_color' => '#FFFFFF', 'caption_gap' => 24, - 'caption_margin_right' => 40, 'caption_max_lines' => 2, 'caption_chars_per_line' => 52, 'caption_line_height' => 1.3, @@ -79,7 +82,6 @@ class PRAutoBlogger_Image_Composer_Layout { 'caption_color' => self::COLOR_INK, 'caption_max_lines' => 3, 'caption_chars_per_line' => 36, - 'caption_side_padding' => 64, 'caption_line_height' => 1.3, ), ); diff --git a/includes/core/class-post-assembler.php b/includes/core/class-post-assembler.php index 80f9dc6..6e299e8 100644 --- a/includes/core/class-post-assembler.php +++ b/includes/core/class-post-assembler.php @@ -91,7 +91,9 @@ class PRAutoBlogger_Post_Assembler { * are generated. Its cost is logged with the run_id but no post_id. After all * articles finish, this method divides the research cost evenly and inserts * one 'llm_research' row per article so the cost breakdown popover includes - * the amortized research overhead. + * the amortized research overhead. The audit fields prompt_version and + * agent_role are carried from the original research row (v0.19.4 fix; + * prior builds left them NULL/empty — see phase-1 thread seq 49). * * Side effects: database SELECT, INSERT, DELETE on generation_log. * @@ -109,7 +111,7 @@ class PRAutoBlogger_Post_Assembler { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $research_row = $wpdb->get_row( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared - "SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens + "SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens, prompt_version, agent_role FROM {$table} WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL LIMIT 1", @@ -158,6 +160,8 @@ class PRAutoBlogger_Post_Assembler { 'estimated_cost' => $amortized_cost, 'response_status' => 'success', 'error_message' => '', + 'prompt_version' => $research_row->prompt_version, + 'agent_role' => $research_row->agent_role ?? '', 'created_at' => current_time( 'mysql' ), ) ); diff --git a/includes/providers/class-runware-catalog-fetcher.php b/includes/providers/class-runware-catalog-fetcher.php index 5bbdae9..d67b722 100644 --- a/includes/providers/class-runware-catalog-fetcher.php +++ b/includes/providers/class-runware-catalog-fetcher.php @@ -138,7 +138,8 @@ class PRAutoBlogger_Runware_Catalog_Fetcher { * @param int $offset Pagination offset (0-based). * @param int $limit Page size. * @return array{results: array>, totalResults: int} - * @throws \RuntimeException On HTTP error or malformed response. + * @throws \RuntimeException On HTTP error or malformed response (non-200 exceptions + * include up to 200 chars of the response body for diagnosis). */ private function fetch_page( string $api_key, int $offset, int $limit ): array { $payload = array( @@ -171,8 +172,10 @@ class PRAutoBlogger_Runware_Catalog_Fetcher { $status = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $status ) { + $raw_body = (string) wp_remote_retrieve_body( $response ); + $body_excerpt = '' !== $raw_body ? ' — body: ' . substr( $raw_body, 0, 200 ) : ''; throw new \RuntimeException( - sprintf( 'Runware modelSearch returned HTTP %d', $status ) + sprintf( 'Runware modelSearch returned HTTP %d%s', $status, $body_excerpt ) ); } diff --git a/prautoblogger.php b/prautoblogger.php index 5e26004..400c0cc 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,7 @@ * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.19.3 + * Version: 0.19.4 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | Defined here so every file in the plugin can reference paths, versions, | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.19.3' ); +define( 'PRAUTOBLOGGER_VERSION', '0.19.4' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/tests/unit/Core/ImageComposerEditorTest.php b/tests/unit/Core/ImageComposerEditorTest.php new file mode 100644 index 0000000..1755d0c --- /dev/null +++ b/tests/unit/Core/ImageComposerEditorTest.php @@ -0,0 +1,246 @@ +stub_get_option( [] ); // Logger reads prautoblogger_log_level; fall back to default. + } + + /** Minimal layout geometry mirroring Layout::defaults() relevant keys. */ + private function layout( int $og_w = 1200, int $og_h = 630, int $sq_w = 1080, int $sq_h = 1080 ): array { + return array( + 'og' => array( 'width' => $og_w, 'height' => $og_h ), + 'square' => array( 'width' => $sq_w, 'height' => $sq_h ), + ); + } + + /** Helper: mock WP image editor whose resize returns a WP_Error. */ + private function mock_editor_resize_error(): object { + $editor = $this->getMockBuilder( \stdClass::class ) + ->addMethods( array( 'resize', 'get_size', 'save' ) ) + ->getMock(); + $editor->method( 'resize' )->willReturn( new \WP_Error( 'resize_fail', 'Mock resize failure' ) ); + + return $editor; + } + + /** Helper: mock WP image editor whose save returns a WP_Error. */ + private function mock_editor_save_error( int $w, int $h ): object { + $editor = $this->getMockBuilder( \stdClass::class ) + ->addMethods( array( 'resize', 'get_size', 'save' ) ) + ->getMock(); + $editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success. + $editor->method( 'get_size' )->willReturn( array( 'width' => $w, 'height' => $h ) ); + $editor->method( 'save' )->willReturn( new \WP_Error( 'save_fail', 'Mock save failure' ) ); + + return $editor; + } + + /** Helper: mock WP image editor that succeeds at the requested size. */ + private function mock_editor_success( int $w, int $h ): object { + $editor = $this->getMockBuilder( \stdClass::class ) + ->addMethods( array( 'resize', 'get_size', 'save' ) ) + ->getMock(); + $editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success. + $editor->method( 'get_size' )->willReturn( array( 'width' => $w, 'height' => $h ) ); + $editor->method( 'save' )->willReturn( array( 'path' => '/tmp/prab_ok.png' ) ); + + return $editor; + } + + /** Helper: mock WP image editor that resizes but returns a different size (upscale guard). */ + private function mock_editor_wrong_size( int $requested_w, int $requested_h, int $actual_w, int $actual_h ): object { + $editor = $this->getMockBuilder( \stdClass::class ) + ->addMethods( array( 'resize', 'get_size', 'save' ) ) + ->getMock(); + $editor->method( 'resize' )->willReturn( false ); // Not WP_Error = success. + $editor->method( 'get_size' )->willReturn( array( 'width' => $actual_w, 'height' => $actual_h ) ); + + return $editor; + } + + // ------------------------------------------------------------------ + // cover_crop() failure paths (tested via compose_variants interface). + // ------------------------------------------------------------------ + + /** + * wp_get_image_editor() returns WP_Error → role skipped, + * no variants emitted. + */ + public function test_editor_wp_error_skips_role(): void { + Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' ); + Functions\when( 'wp_get_image_editor' )->justReturn( + new \WP_Error( 'no_editor', 'No image editor available' ) + ); + Functions\when( 'is_wp_error' )->alias( static function ( $val ) { + return $val instanceof \WP_Error; + } ); + + $editor = new \PRAutoBlogger_Image_Composer_Editor(); + $variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() ); + + $this->assertSame( array(), $variants, 'wp_get_image_editor WP_Error must yield no variants' ); + } + + /** + * Resize step returns WP_Error → role skipped. + */ + public function test_resize_wp_error_skips_role(): void { + Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' ); + Functions\when( 'wp_get_image_editor' )->justReturn( $this->mock_editor_resize_error() ); + Functions\when( 'is_wp_error' )->alias( static function ( $val ) { + return $val instanceof \WP_Error; + } ); + + $editor = new \PRAutoBlogger_Image_Composer_Editor(); + $variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() ); + + $this->assertSame( array(), $variants, 'Resize WP_Error must yield no variants' ); + } + + /** + * Exact size not reachable (WP editor silently gives a different size + * instead of upscaling) → role skipped via the size-mismatch guard. + */ + public function test_size_mismatch_skips_role(): void { + Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' ); + // Editor resizes to 900×473 when 1080×1080 square was requested (would upscale). + Functions\when( 'wp_get_image_editor' )->justReturn( + $this->mock_editor_wrong_size( 1080, 1080, 900, 473 ) + ); + Functions\when( 'is_wp_error' )->alias( static function ( $val ) { + return $val instanceof \WP_Error; + } ); + + $editor = new \PRAutoBlogger_Image_Composer_Editor(); + $variants = $editor->compose_variants( 'fake_bytes', array( 'square' ), $this->layout() ); + + $this->assertSame( array(), $variants, 'Size mismatch (upscale guard) must yield no variants' ); + } + + /** + * Save step returns WP_Error → role skipped. + */ + public function test_save_wp_error_skips_role(): void { + Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' ); + Functions\when( 'wp_get_image_editor' )->justReturn( + $this->mock_editor_save_error( 1200, 630 ) + ); + Functions\when( 'is_wp_error' )->alias( static function ( $val ) { + return $val instanceof \WP_Error; + } ); + + $editor = new \PRAutoBlogger_Image_Composer_Editor(); + $variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $this->layout() ); + + $this->assertSame( array(), $variants, 'Save WP_Error must yield no variants' ); + } + + // ------------------------------------------------------------------ + // Happy path — OG resizes successfully. + // ------------------------------------------------------------------ + + /** + * Successful cover_crop for 'og': variant shape matches expectations. + * file_put_contents and file_get_contents are exercised via temp file + * in the test runner's writable temp dir; the editor mock does not + * actually modify the file, but the class reads it back and returns its + * bytes. + */ + public function test_successful_og_resize_returns_correct_shape(): void { + $temp_dir = sys_get_temp_dir() . '/'; + Functions\when( 'get_temp_dir' )->justReturn( $temp_dir ); + + $og_w = 1200; + $og_h = 630; + + Functions\when( 'wp_get_image_editor' )->alias( + function () use ( $og_w, $og_h ) { + return $this->mock_editor_success( $og_w, $og_h ); + } + ); + Functions\when( 'is_wp_error' )->alias( static function ( $val ) { + return $val instanceof \WP_Error; + } ); + + $editor = new \PRAutoBlogger_Image_Composer_Editor(); + $variants = $editor->compose_variants( 'fake_source_bytes', array( 'og' ), $this->layout() ); + + $this->assertCount( 1, $variants, 'Successful resize must emit exactly one variant' ); + $variant = $variants[0]; + $this->assertSame( 'og', $variant['role'] ); + $this->assertSame( 'image/png', $variant['mime_type'] ); + $this->assertSame( $og_w, $variant['width'] ); + $this->assertSame( $og_h, $variant['height'] ); + $this->assertIsString( $variant['bytes'] ); + } + + // ------------------------------------------------------------------ + // compose_variants() geometry guard. + // ------------------------------------------------------------------ + + /** + * A role whose layout target has width=0 is skipped before any I/O — + * no editor call, no variant emitted. + */ + public function test_zero_width_target_skips_role_before_editor(): void { + // Make the 'og' slot have zero width so the guard fires before any I/O. + $layout = array( + 'og' => array( 'width' => 0, 'height' => 630 ), + ); + + // wp_get_image_editor must never be called. + Functions\expect( 'wp_get_image_editor' )->never(); + Functions\when( 'is_wp_error' )->alias( static function ( $val ) { + return $val instanceof \WP_Error; + } ); + + $editor = new \PRAutoBlogger_Image_Composer_Editor(); + $variants = $editor->compose_variants( 'fake_bytes', array( 'og' ), $layout ); + + $this->assertSame( array(), $variants ); + } + + /** + * Role not present in layout skips cleanly. + */ + public function test_missing_role_in_layout_emits_no_variant(): void { + Functions\expect( 'wp_get_image_editor' )->never(); + Functions\when( 'is_wp_error' )->alias( static function ( $val ) { + return $val instanceof \WP_Error; + } ); + + $editor = new \PRAutoBlogger_Image_Composer_Editor(); + $variants = $editor->compose_variants( 'fake_bytes', array( 'nonexistent_role' ), array() ); + + $this->assertSame( array(), $variants ); + } +} diff --git a/tests/unit/Core/ImageComposerTest.php b/tests/unit/Core/ImageComposerTest.php index 5c6b204..3df7596 100644 --- a/tests/unit/Core/ImageComposerTest.php +++ b/tests/unit/Core/ImageComposerTest.php @@ -322,4 +322,43 @@ class ImageComposerTest extends BaseTestCase { $this->assertStringStartsWith( 'prautoblogger_', $key ); } } + + /** + * Ladder rung 2 forced via the override filter ('gd'): compose() emits the + * pass-through featured variant as first element, and fires exactly one WARNING. + * The Imagick renderer must never be called on this rung. + * + * Source: QA verdict 2026-06-11-6f76df1.md P2-1 and CTO handoff + * convo/prautoblogger/threads/2026-06-maintenance-v0194/01-cto-maintenance-handoff.md item 1. + */ + public function test_gd_rung_emits_passthrough_featured_and_single_warning(): void { + Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'gd' ); + + // Imagick renderer must not be invoked on the gd rung. + $renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class ); + $renderer->expects( $this->never() )->method( 'compose_featured' ); + $renderer->expects( $this->never() )->method( 'compose_og' ); + $renderer->expects( $this->never() )->method( 'compose_square' ); + + // Stub the WP functions that cover_crop() calls inside the GD rung. + // get_temp_dir() is called first; wp_get_image_editor() returning WP_Error + // makes cover_crop() return null for every role → compose_variants() returns [] + // → the GD rung emits only the pass-through featured (array_merge($passthrough, [])). + // This is the real degradation-to-passthrough path the test is designed to assert. + Functions\when( 'get_temp_dir' )->justReturn( sys_get_temp_dir() . '/' ); + Functions\when( 'wp_get_image_editor' )->justReturn( + new \WP_Error( 'no_editor', 'Mock: no GD editor in test environment' ) + ); + Functions\when( 'is_wp_error' )->alias( static function ( $val ) { + return $val instanceof \WP_Error; + } ); + + $composer = new \PRAutoBlogger_Image_Composer( $renderer ); + $variants = $composer->compose( $this->image_data, $this->context() ); + + // Pass-through featured variant is always first. + $this->assertNotEmpty( $variants ); + $this->assertSame( 'featured', $variants[0]['role'] ); + $this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] ); + } } diff --git a/tests/unit/Core/ResearchReaperTest.php b/tests/unit/Core/ResearchReaperTest.php index e97a973..0b18299 100644 --- a/tests/unit/Core/ResearchReaperTest.php +++ b/tests/unit/Core/ResearchReaperTest.php @@ -110,6 +110,8 @@ class ResearchReaperTest extends BaseTestCase { 'model' => $o->model ?? 'gemini-flash', 'prompt_tokens' => $o->prompt_tokens ?? 1000, 'completion_tokens' => $o->completion_tokens ?? 500, + 'prompt_version' => $o->prompt_version ?? null, + 'agent_role' => $o->agent_role ?? '', ]; } }