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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user