feat: deterministic image composer — OG + square variants, branding, baked caption (v0.17.0)
Commit 2 (final scope) of the in-plugin editorial image pipeline (ARCHITECTURE.md #21). A deterministic local composer now runs between provider bytes and sideload: Image A yields a corner-marked featured (1200x632) + branded OG (1200x630, teal band, logo, baked caption in Poppins SemiBold) + square card (1080x1080, cream panel, Poppins Bold, teal footer with the reverse lockup); Image B gets the corner mark only. - Capability ladder: imagick (full compositing) -> wp_get_image_editor resize-only (unbranded, WARNING once) -> pass-through (WARNING once); probe cached in prautoblogger_image_compose_capability, fingerprinted by PHP version + imagick/gd presence so host flips auto-invalidate. compose() never throws; composition can never fail a publish. - Variants sideloaded with a role filename suffix; attachment meta _prautoblogger_image_role + _prautoblogger_base_attachment_id; post meta _prautoblogger_og_image_id (frozen for the rebuilt SEO stage) + _prautoblogger_square_image_id. No cost-tracker rows ($0 local stage; duration logged on the image_composer channel). - New Images settings: compose_enabled ('1'), compose_variants ('og,square'), featured_mark_enabled ('1') — read at point of use. Geometry ships as Layout defaults behind the prautoblogger_image_compose_layout filter. - Vendored brand rasters (assets/brand) + bundled OFL Poppins faces (assets/fonts) for per-environment byte-stable output (stripImage + PNG date/time chunks excluded). Validated on Hostinger PHP 8.3.30 / ImageMagick 7.1.1-43: php -l clean on all touched files, double render byte-identical (sha256), exact variant geometry. - Fix: featured/Image B alt text is the editorial caption (was the model name); model still persists in _prautoblogger_image_model. - Image_Attacher extracted from Image_Pipeline (300-line compliance); sideloader gains an optional filename suffix param (back-compat ''). - Version 0.16.0 -> 0.17.0 + CHANGELOG + ARCHITECTURE.md #21 + CONVENTIONS.md composer guide in this commit. Unit tests: layout clamp boundaries, capability ladder, cache invalidation, attacher meta-linking, pipeline integration, imagick determinism (gated). Agent-Session: engineer-prautoblogger-image-composer-20260611
This commit is contained in:
176
tests/unit/Core/ImageAttacherTest.php
Normal file
176
tests/unit/Core/ImageAttacherTest.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Attacher.
|
||||
*
|
||||
* Validates primary sideload + cost logging (including the v0.17.0 alt-text
|
||||
* fix: the caption, not the model name, becomes alt text) and composed
|
||||
* variant persistence with role/base meta-linking at $0 cost.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ImageAttacherTest extends BaseTestCase {
|
||||
|
||||
/** @var array Captured update_post_meta calls: [object_id, key, value]. */
|
||||
private array $meta_calls = [];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
Functions\when( 'is_wp_error' )->alias(
|
||||
static function ( $thing ) {
|
||||
return $thing instanceof \WP_Error;
|
||||
}
|
||||
);
|
||||
$this->meta_calls = [];
|
||||
Functions\when( 'update_post_meta' )->alias(
|
||||
function ( $object_id, $key, $value ) {
|
||||
$this->meta_calls[] = [ $object_id, $key, $value ];
|
||||
return true;
|
||||
}
|
||||
);
|
||||
$this->stub_get_option( [] );
|
||||
}
|
||||
|
||||
/** Helper: find a captured meta value by object id + key. */
|
||||
private function meta_value( int $object_id, string $key ) {
|
||||
foreach ( $this->meta_calls as $call ) {
|
||||
if ( $call[0] === $object_id && $call[1] === $key ) {
|
||||
return $call[2];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Helper: primary image fixture. */
|
||||
private function image_data(): array {
|
||||
return [
|
||||
'bytes' => 'featured_bytes',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 632,
|
||||
'model' => 'flux-1-schnell',
|
||||
'cost_usd' => 0.0006,
|
||||
'latency_ms' => 1500,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* sideload_and_log() forwards the caption as alt text (bug fix: the model
|
||||
* name used to leak into alt text) and logs the generation cost once.
|
||||
*/
|
||||
public function test_sideload_and_log_forwards_caption_as_alt_and_logs_cost(): void {
|
||||
$sideloader = $this->createMock( \PRAutoBlogger_Image_Media_Sideloader::class );
|
||||
$sideloader->expects( $this->once() )
|
||||
->method( 'sideload_image' )
|
||||
->with( $this->image_data(), 7, 'Collagen fragments may aid joint repair' )
|
||||
->willReturn( 42 );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->expects( $this->once() )
|
||||
->method( 'log_image_generation' )
|
||||
->with( 0.0006, 'flux-1-schnell', 7, 'image_a' );
|
||||
|
||||
$attacher = new \PRAutoBlogger_Image_Attacher( $sideloader, $cost_tracker );
|
||||
$result = $attacher->sideload_and_log( $this->image_data(), 7, 'image_a', 'Image A', 'Collagen fragments may aid joint repair' );
|
||||
|
||||
$this->assertSame( 42, $result );
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed primary sideload returns the WP_Error untouched and logs no cost.
|
||||
*/
|
||||
public function test_sideload_and_log_propagates_error_without_cost_row(): void {
|
||||
$error = new \WP_Error( 'sideload_failed', 'disk full' );
|
||||
$sideloader = $this->createMock( \PRAutoBlogger_Image_Media_Sideloader::class );
|
||||
$sideloader->method( 'sideload_image' )->willReturn( $error );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->expects( $this->never() )->method( 'log_image_generation' );
|
||||
|
||||
$attacher = new \PRAutoBlogger_Image_Attacher( $sideloader, $cost_tracker );
|
||||
|
||||
$this->assertSame( $error, $attacher->sideload_and_log( $this->image_data(), 7, 'image_a', 'Image A', 'cap' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* attach_variants() sideloads og + square with the role as filename
|
||||
* suffix and the caption as alt text, writes role/base attachment meta
|
||||
* and the role-specific post meta, and never logs composition cost.
|
||||
*/
|
||||
public function test_attach_variants_meta_links_og_and_square(): void {
|
||||
$variants = [
|
||||
[
|
||||
'bytes' => 'og_bytes',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'role' => 'og',
|
||||
],
|
||||
[
|
||||
'bytes' => 'square_bytes',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1080,
|
||||
'height' => 1080,
|
||||
'role' => 'square',
|
||||
],
|
||||
];
|
||||
|
||||
$returned_ids = [ 'og' => 43, 'square' => 44 ];
|
||||
$sideloader = $this->createMock( \PRAutoBlogger_Image_Media_Sideloader::class );
|
||||
$sideloader->expects( $this->exactly( 2 ) )
|
||||
->method( 'sideload_image' )
|
||||
->willReturnCallback(
|
||||
function ( array $image_data, int $post_id, string $alt, string $suffix ) use ( $returned_ids ) {
|
||||
$this->assertSame( 7, $post_id );
|
||||
$this->assertSame( 'A short caption', $alt );
|
||||
$this->assertSame( 0.0, $image_data['cost_usd'] );
|
||||
$this->assertSame( 'flux-1-schnell', $image_data['model'] );
|
||||
$this->assertArrayHasKey( $suffix, $returned_ids, 'filename suffix must be the role' );
|
||||
return $returned_ids[ $suffix ];
|
||||
}
|
||||
);
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->expects( $this->never() )->method( 'log_image_generation' );
|
||||
|
||||
$attacher = new \PRAutoBlogger_Image_Attacher( $sideloader, $cost_tracker );
|
||||
$attacher->attach_variants( $variants, 7, 42, 'A short caption', 'flux-1-schnell' );
|
||||
|
||||
$this->assertSame( 'og', $this->meta_value( 43, '_prautoblogger_image_role' ) );
|
||||
$this->assertSame( 42, $this->meta_value( 43, '_prautoblogger_base_attachment_id' ) );
|
||||
$this->assertSame( 43, $this->meta_value( 7, '_prautoblogger_og_image_id' ) );
|
||||
|
||||
$this->assertSame( 'square', $this->meta_value( 44, '_prautoblogger_image_role' ) );
|
||||
$this->assertSame( 42, $this->meta_value( 44, '_prautoblogger_base_attachment_id' ) );
|
||||
$this->assertSame( 44, $this->meta_value( 7, '_prautoblogger_square_image_id' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown roles (e.g. a stray featured entry) are ignored, and a failed
|
||||
* variant sideload is skipped without meta writes or exceptions.
|
||||
*/
|
||||
public function test_attach_variants_skips_unknown_roles_and_failed_sideloads(): void {
|
||||
$variants = [
|
||||
[ 'bytes' => 'f', 'mime_type' => 'image/png', 'width' => 1, 'height' => 1, 'role' => 'featured' ],
|
||||
[ 'bytes' => 'o', 'mime_type' => 'image/png', 'width' => 1200, 'height' => 630, 'role' => 'og' ],
|
||||
];
|
||||
|
||||
$sideloader = $this->createMock( \PRAutoBlogger_Image_Media_Sideloader::class );
|
||||
$sideloader->expects( $this->once() ) // Only og — featured is not a variant role here.
|
||||
->method( 'sideload_image' )
|
||||
->willReturn( new \WP_Error( 'sideload_failed', 'nope' ) );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
|
||||
$attacher = new \PRAutoBlogger_Image_Attacher( $sideloader, $cost_tracker );
|
||||
$attacher->attach_variants( $variants, 7, 42, 'cap', 'model' );
|
||||
|
||||
$this->assertSame( [], $this->meta_calls );
|
||||
}
|
||||
}
|
||||
104
tests/unit/Core/ImageComposerImagickTest.php
Normal file
104
tests/unit/Core/ImageComposerImagickTest.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Composer_Imagick (real renders).
|
||||
*
|
||||
* These run only where ext-imagick is loaded (skipped on the standard CI
|
||||
* matrix); the same renders are exercised by the standalone harness that
|
||||
* produced the PR samples. Asserts the determinism contract — identical
|
||||
* inputs yield byte-identical output in the same environment — plus the
|
||||
* exact variant geometry.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class ImageComposerImagickTest extends BaseTestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
if ( ! extension_loaded( 'imagick' ) ) {
|
||||
$this->markTestSkipped( 'ext-imagick not available — renderer covered by the server-side harness.' );
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper: renderer wired to the real bundled assets. */
|
||||
private function renderer(): \PRAutoBlogger_Image_Composer_Imagick {
|
||||
$root = dirname( __DIR__, 3 );
|
||||
|
||||
return new \PRAutoBlogger_Image_Composer_Imagick(
|
||||
\PRAutoBlogger_Image_Composer_Layout::defaults(),
|
||||
$root . '/assets/brand/',
|
||||
$root . '/assets/fonts/'
|
||||
);
|
||||
}
|
||||
|
||||
/** Helper: deterministic 1200×632 fixture base image (gradient + disc). */
|
||||
private function fixture_bytes(): string {
|
||||
$image = new \Imagick();
|
||||
$image->newPseudoImage( 1200, 632, 'gradient:#1B8A92-#FAFAF7' );
|
||||
$draw = new \ImagickDraw();
|
||||
$draw->setFillColor( new \ImagickPixel( '#FF8A3D' ) );
|
||||
$draw->circle( 600, 316, 600, 470 );
|
||||
$image->drawImage( $draw );
|
||||
$image->setImageFormat( 'png' );
|
||||
|
||||
return $image->getImageBlob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determinism: rendering the same input twice yields byte-identical
|
||||
* variants (metadata stripped, PNG date/time chunks excluded).
|
||||
*/
|
||||
public function test_same_input_renders_byte_identical_output(): void {
|
||||
$bytes = $this->fixture_bytes();
|
||||
$caption = 'Collagen fragments may aid joint repair';
|
||||
|
||||
$first = $this->renderer()->compose_og( $bytes, $caption );
|
||||
$second = $this->renderer()->compose_og( $bytes, $caption );
|
||||
$this->assertSame( hash( 'sha256', $first['bytes'] ), hash( 'sha256', $second['bytes'] ), 'OG render must be byte-stable' );
|
||||
|
||||
$first = $this->renderer()->compose_square( $bytes, $caption );
|
||||
$second = $this->renderer()->compose_square( $bytes, $caption );
|
||||
$this->assertSame( hash( 'sha256', $first['bytes'] ), hash( 'sha256', $second['bytes'] ), 'Square render must be byte-stable' );
|
||||
|
||||
$first = $this->renderer()->compose_featured( $bytes );
|
||||
$second = $this->renderer()->compose_featured( $bytes );
|
||||
$this->assertSame( hash( 'sha256', $first['bytes'] ), hash( 'sha256', $second['bytes'] ), 'Featured render must be byte-stable' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant geometry matches the spec: featured keeps base dimensions,
|
||||
* OG is exactly 1200×630, square exactly 1080×1080, all PNG.
|
||||
*/
|
||||
public function test_variant_dimensions_and_mime(): void {
|
||||
$bytes = $this->fixture_bytes();
|
||||
$caption = 'Collagen fragments may aid joint repair';
|
||||
|
||||
$featured = $this->renderer()->compose_featured( $bytes );
|
||||
$this->assertSame( [ 1200, 632, 'image/png', 'featured' ], [ $featured['width'], $featured['height'], $featured['mime_type'], $featured['role'] ] );
|
||||
|
||||
$og = $this->renderer()->compose_og( $bytes, $caption );
|
||||
$this->assertSame( [ 1200, 630, 'image/png', 'og' ], [ $og['width'], $og['height'], $og['mime_type'], $og['role'] ] );
|
||||
|
||||
$square = $this->renderer()->compose_square( $bytes, $caption );
|
||||
$this->assertSame( [ 1080, 1080, 'image/png', 'square' ], [ $square['width'], $square['height'], $square['mime_type'], $square['role'] ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Output PNG bytes embed no date text chunks or tIME chunk, so a render
|
||||
* is stable across wall-clock time, not just within one process. (We
|
||||
* scan the raw bytes: Imagick synthesizes `date:*` image properties at
|
||||
* read time even for chunk-free PNGs, so getImageProperties() can't
|
||||
* distinguish embedded dates from synthesized ones.)
|
||||
*/
|
||||
public function test_output_contains_no_date_chunks(): void {
|
||||
$og = $this->renderer()->compose_og( $this->fixture_bytes(), 'Short caption' );
|
||||
|
||||
$this->assertFalse( strpos( $og['bytes'], 'date:create' ), 'PNG must not embed a date:create text chunk' );
|
||||
$this->assertFalse( strpos( $og['bytes'], 'date:modify' ), 'PNG must not embed a date:modify text chunk' );
|
||||
$this->assertFalse( strpos( $og['bytes'], 'tIME' ), 'PNG must not embed a tIME chunk' );
|
||||
}
|
||||
}
|
||||
144
tests/unit/Core/ImageComposerLayoutTest.php
Normal file
144
tests/unit/Core/ImageComposerLayoutTest.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Composer_Layout.
|
||||
*
|
||||
* Validates the caption clamp (word wrap, hard budgets, ellipsis, multibyte)
|
||||
* and the shape of the layout defaults consumed by the renderers.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class ImageComposerLayoutTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Empty and whitespace-only captions clamp to no lines at all.
|
||||
*/
|
||||
public function test_empty_caption_returns_no_lines(): void {
|
||||
$this->assertSame( [], \PRAutoBlogger_Image_Composer_Layout::clamp_caption( '', 52, 2 ) );
|
||||
$this->assertSame( [], \PRAutoBlogger_Image_Composer_Layout::clamp_caption( " \n\t ", 52, 2 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* A single short word stays on one line, untouched.
|
||||
*/
|
||||
public function test_single_word_caption(): void {
|
||||
$this->assertSame(
|
||||
[ 'Collagen' ],
|
||||
\PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'Collagen', 52, 2 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A caption exactly at the per-line budget fills the line with no
|
||||
* ellipsis and no spill.
|
||||
*/
|
||||
public function test_caption_exactly_at_budget_is_not_truncated(): void {
|
||||
$caption = 'abcde fghij'; // 11 chars, budget 11.
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( $caption, 11, 1 );
|
||||
|
||||
$this->assertSame( [ 'abcde fghij' ], $lines );
|
||||
}
|
||||
|
||||
/**
|
||||
* A caption exactly filling max_lines is kept in full (boundary: the
|
||||
* ellipsis only appears when content is actually dropped).
|
||||
*/
|
||||
public function test_caption_exactly_filling_max_lines_keeps_all_words(): void {
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'aa bb cc dd', 5, 2 );
|
||||
|
||||
$this->assertSame( [ 'aa bb', 'cc dd' ], $lines );
|
||||
}
|
||||
|
||||
/**
|
||||
* 3× the budget gets cut at max_lines with a trailing ellipsis, and no
|
||||
* line ever exceeds the per-line budget.
|
||||
*/
|
||||
public function test_overflowing_caption_is_ellipsized_within_budget(): void {
|
||||
$caption = 'Collagen fragments may aid joint repair according to early trials in athletes';
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( $caption, 20, 2 );
|
||||
|
||||
$this->assertCount( 2, $lines );
|
||||
$this->assertStringEndsWith( \PRAutoBlogger_Image_Composer_Layout::ELLIPSIS, $lines[1] );
|
||||
foreach ( $lines as $line ) {
|
||||
$this->assertLessThanOrEqual( 20, mb_strlen( $line ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Words wrap at word boundaries — a word never straddles two lines when
|
||||
* it fits on the next one.
|
||||
*/
|
||||
public function test_wraps_at_word_boundaries(): void {
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'peptide bonds form chains', 13, 3 );
|
||||
|
||||
$this->assertSame( [ 'peptide bonds', 'form chains' ], $lines );
|
||||
}
|
||||
|
||||
/**
|
||||
* A single word longer than a full line is hard-split instead of
|
||||
* overflowing the canvas.
|
||||
*/
|
||||
public function test_overlong_single_word_is_hard_split(): void {
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'abcdefghij', 4, 3 );
|
||||
|
||||
$this->assertSame( [ 'abcd', 'efgh', 'ij' ], $lines );
|
||||
}
|
||||
|
||||
/**
|
||||
* Multibyte captions are measured in characters, not bytes, and the
|
||||
* ellipsis path keeps each line within the budget.
|
||||
*/
|
||||
public function test_multibyte_caption_counts_characters_not_bytes(): void {
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( 'ペプチドは結合組織を強化する可能性がある', 6, 2 );
|
||||
|
||||
$this->assertCount( 2, $lines );
|
||||
foreach ( $lines as $line ) {
|
||||
$this->assertLessThanOrEqual( 6, mb_strlen( $line ) );
|
||||
}
|
||||
$this->assertStringEndsWith( \PRAutoBlogger_Image_Composer_Layout::ELLIPSIS, $lines[1] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Interior whitespace runs (newlines, tabs, doubles) collapse to single
|
||||
* spaces before wrapping.
|
||||
*/
|
||||
public function test_whitespace_is_collapsed_before_wrapping(): void {
|
||||
$lines = \PRAutoBlogger_Image_Composer_Layout::clamp_caption( "joint \n\t repair", 20, 2 );
|
||||
|
||||
$this->assertSame( [ 'joint repair' ], $lines );
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout defaults carry every key the renderers consume, with the
|
||||
* spec'd headline geometry (band 120, square slice 569, footer 96).
|
||||
*/
|
||||
public function test_defaults_shape_matches_renderer_contract(): void {
|
||||
$defaults = \PRAutoBlogger_Image_Composer_Layout::defaults();
|
||||
|
||||
$this->assertSame( [ 'featured', 'og', 'square' ], array_keys( $defaults ) );
|
||||
|
||||
$this->assertSame( 28, $defaults['featured']['mark_height'] );
|
||||
$this->assertSame( 0.55, $defaults['featured']['mark_opacity'] );
|
||||
|
||||
$this->assertSame( 1200, $defaults['og']['width'] );
|
||||
$this->assertSame( 630, $defaults['og']['height'] );
|
||||
$this->assertSame( 120, $defaults['og']['band_height'] );
|
||||
$this->assertSame( 2, $defaults['og']['caption_max_lines'] );
|
||||
|
||||
$this->assertSame( 1080, $defaults['square']['width'] );
|
||||
$this->assertSame( 1080, $defaults['square']['height'] );
|
||||
$this->assertSame( 569, $defaults['square']['slice_height'] );
|
||||
$this->assertSame( 96, $defaults['square']['footer_height'] );
|
||||
$this->assertSame( 3, $defaults['square']['caption_max_lines'] );
|
||||
|
||||
foreach ( [ 'og', 'square' ] as $role ) {
|
||||
foreach ( [ 'caption_font', 'caption_size', 'caption_color', 'caption_chars_per_line', 'caption_line_height' ] as $key ) {
|
||||
$this->assertArrayHasKey( $key, $defaults[ $role ], "$role missing $key" );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
317
tests/unit/Core/ImageComposerTest.php
Normal file
317
tests/unit/Core/ImageComposerTest.php
Normal file
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Image_Composer (orchestrator).
|
||||
*
|
||||
* Validates the degradation ladder (Imagick mocked present/absent via the
|
||||
* capability filter), pass-through guarantees, variant config whitelisting,
|
||||
* capability caching, and the uninstall prefix contract.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
use Brain\Monkey\Filters;
|
||||
|
||||
class ImageComposerTest extends BaseTestCase {
|
||||
|
||||
/** @var array Default provider-result fixture fed into compose(). */
|
||||
private array $image_data = [
|
||||
'bytes' => 'raw_base_bytes',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 632,
|
||||
'model' => 'flux-1-schnell',
|
||||
'cost_usd' => 0.0006,
|
||||
'latency_ms' => 1500,
|
||||
];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
\PRAutoBlogger_Image_Composer::reset_run_state();
|
||||
Functions\when( 'update_option' )->justReturn( true );
|
||||
$this->stub_get_option( [] ); // All composer options fall back to defaults.
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Image_Composer::reset_run_state();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** Helper: composer context for Image A. */
|
||||
private function context( string $slot = 'image_a' ): array {
|
||||
return [
|
||||
'post_id' => 7,
|
||||
'caption' => 'Collagen fragments may aid joint repair',
|
||||
'title' => 'Collagen Peptides Explained',
|
||||
'slot' => $slot,
|
||||
];
|
||||
}
|
||||
|
||||
/** Helper: a fake rendered variant payload. */
|
||||
private function variant( string $role, int $w, int $h ): array {
|
||||
return [
|
||||
'bytes' => "rendered_{$role}",
|
||||
'mime_type' => 'image/png',
|
||||
'width' => $w,
|
||||
'height' => $h,
|
||||
'role' => $role,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose disabled in settings → single pass-through featured variant,
|
||||
* renderer untouched.
|
||||
*/
|
||||
public function test_disabled_setting_returns_passthrough_only(): void {
|
||||
$this->stub_get_option( [ \PRAutoBlogger_Image_Composer::OPTION_ENABLED => '0' ] );
|
||||
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->expects( $this->never() )->method( 'compose_featured' );
|
||||
$renderer->expects( $this->never() )->method( 'compose_og' );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertCount( 1, $variants );
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
$this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty provider bytes short-circuit to pass-through (no probe, no render).
|
||||
*/
|
||||
public function test_empty_bytes_return_passthrough(): void {
|
||||
$composer = new \PRAutoBlogger_Image_Composer(
|
||||
$this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class )
|
||||
);
|
||||
$variants = $composer->compose( [ 'bytes' => '' ], $this->context() );
|
||||
|
||||
$this->assertCount( 1, $variants );
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Ladder rung 1 (Imagick "present" via filter + mocked renderer):
|
||||
* Image A yields marked featured + og + square, featured first, with the
|
||||
* caption forwarded to both text-bearing variants.
|
||||
*/
|
||||
public function test_imagick_rung_renders_featured_plus_variants(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' );
|
||||
|
||||
$caption = $this->context()['caption'];
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->expects( $this->once() )->method( 'compose_featured' )
|
||||
->with( 'raw_base_bytes' )->willReturn( $this->variant( 'featured', 1200, 632 ) );
|
||||
$renderer->expects( $this->once() )->method( 'compose_og' )
|
||||
->with( 'raw_base_bytes', $caption )->willReturn( $this->variant( 'og', 1200, 630 ) );
|
||||
$renderer->expects( $this->once() )->method( 'compose_square' )
|
||||
->with( 'raw_base_bytes', $caption )->willReturn( $this->variant( 'square', 1080, 1080 ) );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertSame( [ 'featured', 'og', 'square' ], array_column( $variants, 'role' ) );
|
||||
$this->assertSame( 'rendered_featured', $variants[0]['bytes'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured corner mark toggled off → featured stays the untouched base,
|
||||
* but social variants still render.
|
||||
*/
|
||||
public function test_mark_disabled_passes_featured_through_but_keeps_variants(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' );
|
||||
$this->stub_get_option( [ \PRAutoBlogger_Image_Composer::OPTION_MARK_ENABLED => '0' ] );
|
||||
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->expects( $this->never() )->method( 'compose_featured' );
|
||||
$renderer->method( 'compose_og' )->willReturn( $this->variant( 'og', 1200, 630 ) );
|
||||
$renderer->method( 'compose_square' )->willReturn( $this->variant( 'square', 1080, 1080 ) );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] );
|
||||
$this->assertSame( [ 'featured', 'og', 'square' ], array_column( $variants, 'role' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Image B slot gets the corner mark only — no og/square renders ever.
|
||||
*/
|
||||
public function test_image_b_slot_yields_mark_only(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' );
|
||||
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->expects( $this->once() )->method( 'compose_featured' )
|
||||
->willReturn( $this->variant( 'featured', 1200, 632 ) );
|
||||
$renderer->expects( $this->never() )->method( 'compose_og' );
|
||||
$renderer->expects( $this->never() )->method( 'compose_square' );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context( 'image_b' ) );
|
||||
|
||||
$this->assertCount( 1, $variants );
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* The variant option is whitelisted at point of use: unknown tokens are
|
||||
* dropped, duplicates deduped, config order preserved.
|
||||
*/
|
||||
public function test_variant_config_is_whitelisted_and_ordered(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' );
|
||||
$this->stub_get_option(
|
||||
[ \PRAutoBlogger_Image_Composer::OPTION_VARIANTS => ' square, banana ,og, og' ]
|
||||
);
|
||||
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->method( 'compose_featured' )->willReturn( $this->variant( 'featured', 1200, 632 ) );
|
||||
$renderer->expects( $this->once() )->method( 'compose_square' )
|
||||
->willReturn( $this->variant( 'square', 1080, 1080 ) );
|
||||
$renderer->expects( $this->once() )->method( 'compose_og' )
|
||||
->willReturn( $this->variant( 'og', 1200, 630 ) );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertSame( [ 'featured', 'square', 'og' ], array_column( $variants, 'role' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Acceptance: with every render throwing (e.g. probe lied / Imagick
|
||||
* flipped mid-run), compose() still returns the publishable pass-through
|
||||
* featured variant and never lets the exception escape.
|
||||
*/
|
||||
public function test_renderer_failure_degrades_to_passthrough(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'imagick' );
|
||||
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->method( 'compose_featured' )->willThrowException( new \RuntimeException( 'no imagick after all' ) );
|
||||
$renderer->method( 'compose_og' )->willThrowException( new \RuntimeException( 'no imagick after all' ) );
|
||||
$renderer->method( 'compose_square' )->willThrowException( new \RuntimeException( 'no imagick after all' ) );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertCount( 1, $variants );
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
$this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Ladder rung 3 forced via the override filter: pass-through, no renderer
|
||||
* involvement, publish data intact.
|
||||
*/
|
||||
public function test_capability_none_passes_through(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'none' );
|
||||
|
||||
$renderer = $this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class );
|
||||
$renderer->expects( $this->never() )->method( 'compose_featured' );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer( $renderer );
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertCount( 1, $variants );
|
||||
$this->assertSame( 'raw_base_bytes', $variants[0]['bytes'] );
|
||||
$this->assertSame( 1200, $variants[0]['width'] );
|
||||
$this->assertSame( 632, $variants[0]['height'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* A garbage filter return is coerced to 'none' (pass-through), never an
|
||||
* undefined rung.
|
||||
*/
|
||||
public function test_unknown_capability_value_is_treated_as_none(): void {
|
||||
Filters\expectApplied( 'prautoblogger_image_compose_capability' )->andReturn( 'webgpu' );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer(
|
||||
$this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class )
|
||||
);
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertCount( 1, $variants );
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* A cached probe with a matching environment fingerprint is reused —
|
||||
* no re-probe, no option write.
|
||||
*/
|
||||
public function test_matching_capability_cache_skips_probe_write(): void {
|
||||
$fingerprint = md5(
|
||||
PHP_VERSION . '|' . ( extension_loaded( 'imagick' ) ? 'im1' : 'im0' ) . '|' . ( extension_loaded( 'gd' ) ? 'gd1' : 'gd0' )
|
||||
);
|
||||
$this->stub_get_option(
|
||||
[
|
||||
\PRAutoBlogger_Image_Composer::OPTION_CAPABILITY => [
|
||||
'fingerprint' => $fingerprint,
|
||||
'capability' => 'none',
|
||||
],
|
||||
]
|
||||
);
|
||||
Functions\expect( 'update_option' )->never();
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer(
|
||||
$this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class )
|
||||
);
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* A stale fingerprint (host changed) triggers a fresh probe and a cache
|
||||
* rewrite — the auto-invalidation the spec requires.
|
||||
*/
|
||||
public function test_stale_capability_cache_reprobes_and_rewrites(): void {
|
||||
$this->stub_get_option(
|
||||
[
|
||||
\PRAutoBlogger_Image_Composer::OPTION_CAPABILITY => [
|
||||
'fingerprint' => 'old-host-fingerprint',
|
||||
'capability' => 'imagick',
|
||||
],
|
||||
]
|
||||
);
|
||||
Functions\expect( 'update_option' )
|
||||
->once()
|
||||
->with(
|
||||
\PRAutoBlogger_Image_Composer::OPTION_CAPABILITY,
|
||||
\Mockery::on(
|
||||
static function ( $value ): bool {
|
||||
return is_array( $value )
|
||||
&& isset( $value['fingerprint'], $value['capability'] )
|
||||
&& 'old-host-fingerprint' !== $value['fingerprint'];
|
||||
}
|
||||
),
|
||||
false
|
||||
)
|
||||
->andReturn( true );
|
||||
|
||||
$composer = new \PRAutoBlogger_Image_Composer(
|
||||
$this->createMock( \PRAutoBlogger_Image_Composer_Imagick::class )
|
||||
);
|
||||
$variants = $composer->compose( $this->image_data, $this->context() );
|
||||
|
||||
$this->assertSame( 'featured', $variants[0]['role'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* QA note for the uninstall contract: every new option key keeps the
|
||||
* `prautoblogger_` prefix so uninstall.php's prefix-sweep purges it.
|
||||
* (Per spec, uninstall.php itself is intentionally untouched.)
|
||||
*/
|
||||
public function test_new_option_keys_keep_uninstall_sweep_prefix(): void {
|
||||
$keys = [
|
||||
\PRAutoBlogger_Image_Composer::OPTION_ENABLED,
|
||||
\PRAutoBlogger_Image_Composer::OPTION_VARIANTS,
|
||||
\PRAutoBlogger_Image_Composer::OPTION_MARK_ENABLED,
|
||||
\PRAutoBlogger_Image_Composer::OPTION_CAPABILITY,
|
||||
];
|
||||
|
||||
foreach ( $keys as $key ) {
|
||||
$this->assertStringStartsWith( 'prautoblogger_', $key );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// v0.17.0: the composer caches its capability probe via update_option
|
||||
// and keeps a once-per-run warning flag that must not leak between tests.
|
||||
\PRAutoBlogger_Image_Composer::reset_run_state();
|
||||
Functions\when( 'update_option' )->justReturn( true );
|
||||
|
||||
// Mock WordPress functions used by the pipeline and its dependencies.
|
||||
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
|
||||
return $thing instanceof \WP_Error;
|
||||
@@ -346,6 +351,67 @@ class ImagePipelineTest extends BaseTestCase {
|
||||
$this->assertNotEmpty( $result['errors'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.17.0: composed OG/square variants are sideloaded and meta-linked —
|
||||
* role + base-attachment meta on the variant attachments, role-specific
|
||||
* image ID meta on the post, and the featured attachment tagged with its
|
||||
* role. Composition adds no cost-tracker rows beyond the primary image.
|
||||
*/
|
||||
public function test_composed_variants_are_sideloaded_and_meta_linked(): void {
|
||||
$image_result = [
|
||||
'bytes' => 'fake_image_data',
|
||||
'mime_type' => 'image/png',
|
||||
'width' => 1200,
|
||||
'height' => 632,
|
||||
'model' => 'test-model',
|
||||
'cost_usd' => 0.05,
|
||||
'latency_ms' => 2000,
|
||||
];
|
||||
|
||||
$provider = $this->createMock( \PRAutoBlogger_Image_Provider_Interface::class );
|
||||
$provider->method( 'estimate_cost' )->willReturn( 0.05 );
|
||||
$provider->method( 'generate_image_batch' )->willReturn( [ 'image_a' => $image_result ] );
|
||||
|
||||
$cost_tracker = $this->createMock( \PRAutoBlogger_Cost_Tracker::class );
|
||||
$cost_tracker->method( 'would_exceed_budget' )->willReturn( false );
|
||||
// Exactly one cost row: the primary image. Variants are $0 local renders.
|
||||
$cost_tracker->expects( $this->exactly( 1 ) )->method( 'log_image_generation' );
|
||||
|
||||
$composer = new class() implements \PRAutoBlogger_Image_Composer_Interface {
|
||||
public function compose( array $image_data, array $context ): array {
|
||||
return [
|
||||
[ 'bytes' => 'marked', 'mime_type' => 'image/png', 'width' => 1200, 'height' => 632, 'role' => 'featured' ],
|
||||
[ 'bytes' => 'og_png', 'mime_type' => 'image/png', 'width' => 1200, 'height' => 630, 'role' => 'og' ],
|
||||
[ 'bytes' => 'sq_png', 'mime_type' => 'image/png', 'width' => 1080, 'height' => 1080, 'role' => 'square' ],
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
Functions\when( 'get_temp_dir' )->justReturn( '/tmp/' );
|
||||
$next_id = 41;
|
||||
Functions\when( 'media_handle_sideload' )->alias( function () use ( &$next_id ) {
|
||||
return ++$next_id; // 42 featured, 43 og, 44 square.
|
||||
} );
|
||||
|
||||
$meta_calls = [];
|
||||
Functions\when( 'update_post_meta' )->alias( function ( $object_id, $key, $value ) use ( &$meta_calls ) {
|
||||
$meta_calls[] = [ $object_id, $key, $value ];
|
||||
return true;
|
||||
} );
|
||||
|
||||
$pipeline = new \PRAutoBlogger_Image_Pipeline( $provider, $cost_tracker, null, $composer );
|
||||
$result = $pipeline->generate_and_attach_images( 1, [ 'post_title' => 'Test' ], null );
|
||||
|
||||
$this->assertSame( 42, $result['image_a_id'] );
|
||||
$this->assertContains( [ 42, '_prautoblogger_image_role', 'featured' ], $meta_calls );
|
||||
$this->assertContains( [ 43, '_prautoblogger_image_role', 'og' ], $meta_calls );
|
||||
$this->assertContains( [ 43, '_prautoblogger_base_attachment_id', 42 ], $meta_calls );
|
||||
$this->assertContains( [ 1, '_prautoblogger_og_image_id', 43 ], $meta_calls );
|
||||
$this->assertContains( [ 44, '_prautoblogger_image_role', 'square' ], $meta_calls );
|
||||
$this->assertContains( [ 44, '_prautoblogger_base_attachment_id', 42 ], $meta_calls );
|
||||
$this->assertContains( [ 1, '_prautoblogger_square_image_id', 44 ], $meta_calls );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test graceful handling when one image in the batch returns an error.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user