Files
prautoblogger/tests/unit/Core/ImageComposerLayoutTest.php
peptiderepo 28947a0caa 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
2026-06-11 12:31:03 +07:00

145 lines
5.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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" );
}
}
}
}