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:
peptiderepo
2026-06-11 12:31:03 +07:00
parent d559fc58ab
commit 28947a0caa
26 changed files with 2416 additions and 128 deletions

View 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 );
}
}
}