imageTestRoot = storage_path('framework/testing/square-thumbnails-' . Str::lower(Str::random(10))); File::deleteDirectory($this->imageTestRoot); File::ensureDirectoryExists($this->imageTestRoot); }); afterEach(function () { File::deleteDirectory($this->imageTestRoot); }); function squareThumbCreateImage(string $root, int $width, int $height, ?callable $drawer = null): string { $path = $root . DIRECTORY_SEPARATOR . Str::uuid()->toString() . '.png'; $image = imagecreatetruecolor($width, $height); $background = imagecolorallocate($image, 22, 28, 36); imagefilledrectangle($image, 0, 0, $width, $height, $background); if ($drawer !== null) { $drawer($image, $width, $height); } imagepng($image, $path); imagedestroy($image); return $path; } it('calculates a padded square crop and clamps it to image bounds', function () { $service = new SquareThumbnailService(new NullSubjectDetector()); $crop = $service->calculateCropBox(1200, 800, new CropBoxData(10, 40, 260, 180), [ 'padding_ratio' => 0.2, ]); expect($crop->width)->toBe($crop->height) ->and($crop->x)->toBe(0) ->and($crop->y)->toBeGreaterThanOrEqual(0) ->and($crop->x + $crop->width)->toBeLessThanOrEqual(1200) ->and($crop->y + $crop->height)->toBeLessThanOrEqual(800); }); it('falls back to a centered square crop when no focus box exists', function () { $service = new SquareThumbnailService(new NullSubjectDetector()); $crop = $service->calculateCropBox(900, 500, null); expect($crop->width)->toBe(500) ->and($crop->height)->toBe(500) ->and($crop->x)->toBe(200) ->and($crop->y)->toBe(0); }); it('generates a valid square thumbnail when smart detection is unavailable', function () { $source = squareThumbCreateImage($this->imageTestRoot, 640, 320, function ($image): void { $accent = imagecolorallocate($image, 240, 200, 80); imagefilledellipse($image, 320, 160, 180, 180, $accent); }); $destination = $this->imageTestRoot . DIRECTORY_SEPARATOR . 'output.webp'; $service = new SquareThumbnailService(new NullSubjectDetector()); $result = $service->generateFromPath($source, $destination, ['target_size' => 256]); $size = getimagesize($destination); expect(File::exists($destination))->toBeTrue() ->and($result->cropMode)->toBe('center') ->and($size[0] ?? null)->toBe(256) ->and($size[1] ?? null)->toBe(256); }); it('does not upscale tiny images when allow_upscale is disabled', function () { $source = squareThumbCreateImage($this->imageTestRoot, 60, 40, function ($image): void { $accent = imagecolorallocate($image, 220, 120, 120); imagefilledrectangle($image, 4, 4, 36, 32, $accent); }); $destination = $this->imageTestRoot . DIRECTORY_SEPARATOR . 'tiny.webp'; $service = new SquareThumbnailService(new NullSubjectDetector()); $result = $service->generateFromPath($source, $destination, [ 'target_size' => 256, 'allow_upscale' => false, ]); $size = getimagesize($destination); expect($result->outputWidth)->toBe(40) ->and($result->outputHeight)->toBe(40) ->and($size[0] ?? null)->toBe(40) ->and($size[1] ?? null)->toBe(40); }); it('can derive a saliency crop near the border', function () { $source = squareThumbCreateImage($this->imageTestRoot, 900, 600, function ($image): void { $subject = imagecolorallocate($image, 250, 240, 240); imagefilledellipse($image, 120, 240, 180, 220, $subject); }); $detector = new HeuristicSubjectDetector(); $size = getimagesize($source); $result = $detector->detect($source, (int) $size[0], (int) $size[1]); expect($result)->not->toBeNull() ->and($result?->strategy)->toBe('saliency') ->and($result?->cropBox->x)->toBeLessThan(220); }); it('prefers a distinct subject over textured foliage', function () { $source = squareThumbCreateImage($this->imageTestRoot, 1200, 700, function ($image): void { $sky = imagecolorallocate($image, 165, 205, 255); $leaf = imagecolorallocate($image, 42, 118, 34); $leafDark = imagecolorallocate($image, 28, 88, 24); $branch = imagecolorallocate($image, 96, 72, 52); $fur = imagecolorallocate($image, 242, 236, 228); $ginger = imagecolorallocate($image, 214, 152, 102); $mouth = imagecolorallocate($image, 36, 20, 18); imagefilledrectangle($image, 0, 0, 1200, 700, $sky); for ($i = 0; $i < 28; $i++) { imageline($image, rand(0, 520), rand(0, 340), rand(80, 620), rand(80, 420), $branch); imagefilledellipse($image, rand(40, 560), rand(80, 620), rand(60, 120), rand(20, 60), $leaf); imagefilledellipse($image, rand(40, 560), rand(80, 620), rand(40, 90), rand(16, 44), $leafDark); } imagefilledellipse($image, 890, 300, 420, 500, $fur); imagefilledpolygon($image, [760, 130, 840, 10, 865, 165], 3, $ginger); imagefilledpolygon($image, [930, 165, 1000, 10, 1070, 130], 3, $ginger); imagefilledellipse($image, 885, 355, 150, 220, $mouth); imagefilledellipse($image, 890, 520, 180, 130, $fur); }); $detector = new HeuristicSubjectDetector(); $size = getimagesize($source); $result = $detector->detect($source, (int) $size[0], (int) $size[1]); expect($result)->not->toBeNull() ->and($result?->strategy)->toBe('saliency') ->and($result?->cropBox->x)->toBeGreaterThan(180); });