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
145 lines
5.1 KiB
PHP
145 lines
5.1 KiB
PHP
<?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" );
|
||
}
|
||
}
|
||
}
|
||
}
|