trypost/tests/Unit/Services/Ai/AiImageClientTest.php
Paulo Castellano 148a2f432f feat: AI image generation pipeline and post creation overhaul
Core changes:
- Replace Unsplash slide pipeline with gpt-image-2 via Laravel AI SDK.
  New AiImageClient builds prompts from a Blade template seeded by the
  workspace's ImageStyle enum, content language, brand color (mapped to a
  human-readable name via HexColorName helper) and brand description.
- Drop Template B from TemplateImageGenerator: every slide now renders as
  Template A (full-bleed photo + bottom gradient + white/grey overlay).
  Removes renderTemplateB, roundCorners, blendHex, ensureContrast and the
  closing-slide pipeline.
- StreamPostCreation creates the Post directly and dispatches
  PostCreationReady with post_id; the wizard kills its preview step and
  redirects straight to the post editor on completion. Finalize endpoint
  removed.
- New Workspace.image_style enum field with an 8-option visual picker
  shared by /workspaces/create and /settings/workspace/brand via a single
  BrandForm component (autofill is a prop). 8 sample webp thumbs ship
  under public/images/branding/image-styles/.
- Media items gain optional source ('ai'|'unsplash'|'giphy') and
  source_meta (recipe needed to regenerate AI images later); the gallery
  picker tags Unsplash/Giphy attachments.
- Brand-color autofill: new CssColorFrequencyExtractor parses every
  hex/rgb/hsl value in the homepage CSS, clusters perceptually similar
  shades in CIE LAB (Delta E 76 < 12), filters neutrals and returns the
  most frequent cluster. Solves Tailwind/utility-CSS sites where no
  semantic --primary variable is exposed.
- Credits: gpt-image-2 metered at 15 credits/image (low quality default).
- Layout: AuthSplitLayout right column is sticky/h-svh so the form
  textarea growth no longer stretches the marketing slider.
- i18n cleanup: localized labels follow the no-em-dash convention.
2026-05-08 13:38:30 -03:00

157 lines
5.1 KiB
PHP

<?php
declare(strict_types=1);
use App\Enums\Workspace\ImageStyle;
use App\Services\Ai\AiImageClient;
use Laravel\Ai\Image;
use Laravel\Ai\Prompts\ImagePrompt;
test('generate returns null when keywords are empty', function () {
Image::fake();
$client = new AiImageClient;
expect($client->generate([], ImageStyle::Cinematic))->toBeNull();
Image::assertNothingGenerated();
});
test('generate returns raw bytes when AI succeeds', function () {
$bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
Image::fake([base64_encode($bytes)]);
$client = new AiImageClient;
expect($client->generate(['kitchen', 'morning'], ImageStyle::Illustration))
->toBe($bytes);
});
test('generate uses style-specific prompt prefix', function () {
Image::fake();
$client = new AiImageClient;
$client->generate(['mountain hiker'], ImageStyle::Cinematic);
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('Cinematic photograph')
&& $prompt->contains('mountain hiker'));
});
test('generate maps orientation to portrait', function () {
Image::fake();
$client = new AiImageClient;
$client->generate(['x'], ImageStyle::Cinematic, orientation: 'portrait');
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->isPortrait());
});
test('generate maps orientation to landscape', function () {
Image::fake();
$client = new AiImageClient;
$client->generate(['x'], ImageStyle::Cinematic, orientation: 'landscape');
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->isLandscape());
});
test('generate falls back to square for unknown orientation', function () {
Image::fake();
$client = new AiImageClient;
$client->generate(['x'], ImageStyle::Cinematic, orientation: 'whatever');
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->isSquare());
});
test('generate appends Brazilian Portuguese instruction when language is pt-BR', function () {
Image::fake();
$client = new AiImageClient;
$client->generate(['x'], ImageStyle::Cinematic, language: 'pt-BR');
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('Brazilian Portuguese'));
});
test('generate appends Spanish instruction when language is es', function () {
Image::fake();
$client = new AiImageClient;
$client->generate(['x'], ImageStyle::Cinematic, language: 'es');
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('Spanish'));
});
test('generate defaults to English instruction when language is unknown', function () {
Image::fake();
$client = new AiImageClient;
$client->generate(['x'], ImageStyle::Cinematic, language: 'fr');
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('English'));
});
test('generate appends brand color accent when brandColor is provided', function () {
Image::fake();
$client = new AiImageClient;
$client->generate(['x'], ImageStyle::Cinematic, brandColor: '#f47b20');
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('warm orange')
&& $prompt->contains('small accent'));
});
test('generate omits brand color accent when brandColor is null', function () {
Image::fake();
$client = new AiImageClient;
$client->generate(['x'], ImageStyle::Cinematic);
Image::assertGenerated(fn (ImagePrompt $prompt) => ! $prompt->contains('small accent'));
});
test('generate skips accent when brandColor hex is malformed', function () {
Image::fake();
$client = new AiImageClient;
$client->generate(['x'], ImageStyle::Cinematic, brandColor: 'not-a-hex');
Image::assertGenerated(fn (ImagePrompt $prompt) => ! $prompt->contains('small accent'));
});
test('generate appends brand context when brandDescription is provided', function () {
Image::fake();
$client = new AiImageClient;
$client->generate(['x'], ImageStyle::Cinematic, brandDescription: 'a fitness coaching brand for busy professionals');
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('Brand context')
&& $prompt->contains('fitness coaching'));
});
test('generate truncates brand description longer than 200 chars', function () {
Image::fake();
$longDescription = str_repeat('lorem ipsum ', 50);
$client = new AiImageClient;
$client->generate(['x'], ImageStyle::Cinematic, brandDescription: $longDescription);
Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->contains('Brand context')
&& $prompt->contains('…'));
});
test('generate omits brand context when brandDescription is empty or whitespace', function () {
Image::fake();
$client = new AiImageClient;
$client->generate(['x'], ImageStyle::Cinematic, brandDescription: ' ');
Image::assertGenerated(fn (ImagePrompt $prompt) => ! $prompt->contains('Brand context'));
});
test('generate returns null when SDK throws', function () {
Image::fake(fn () => throw new RuntimeException('boom'));
$client = new AiImageClient;
expect($client->generate(['x'], ImageStyle::Cinematic))->toBeNull();
});