2026-05-03 12:36:50 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Models\SocialAccount;
|
|
|
|
|
use App\Models\Workspace;
|
2026-05-08 16:38:30 +00:00
|
|
|
use App\Services\Ai\AiImageClient;
|
2026-05-03 12:36:50 +00:00
|
|
|
use App\Services\Image\BrandColorMapper;
|
|
|
|
|
use App\Services\Image\TemplateImageGenerator;
|
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
|
use Illuminate\Support\Facades\Storage;
|
2026-05-08 16:38:30 +00:00
|
|
|
use Laravel\Ai\Image;
|
|
|
|
|
|
|
|
|
|
$minimalPng = fn () => base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
|
2026-05-03 12:36:50 +00:00
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
Storage::fake();
|
|
|
|
|
Cache::flush();
|
2026-05-08 16:38:30 +00:00
|
|
|
Image::fake();
|
2026-05-03 12:36:50 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-08 16:38:30 +00:00
|
|
|
test('returns null when AI client cannot generate (no keywords)', function () {
|
|
|
|
|
$service = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
2026-05-03 12:36:50 +00:00
|
|
|
$result = $service->render(
|
|
|
|
|
workspace: Workspace::factory()->make(),
|
|
|
|
|
socialAccount: SocialAccount::factory()->make(['username' => 'testuser', 'display_name' => 'Test User']),
|
|
|
|
|
title: 'Hello',
|
|
|
|
|
body: 'World',
|
2026-05-08 16:38:30 +00:00
|
|
|
imageKeywords: [],
|
2026-05-03 12:36:50 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect($result)->toBeNull();
|
2026-05-08 16:38:30 +00:00
|
|
|
Image::assertNothingGenerated();
|
2026-05-03 12:36:50 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-08 16:38:30 +00:00
|
|
|
test('returns null when AI generation throws', function () {
|
|
|
|
|
Image::fake(fn () => throw new RuntimeException('upstream outage'));
|
2026-05-03 12:36:50 +00:00
|
|
|
|
2026-05-08 16:38:30 +00:00
|
|
|
$service = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
2026-05-03 12:36:50 +00:00
|
|
|
$result = $service->render(
|
|
|
|
|
workspace: Workspace::factory()->make(),
|
|
|
|
|
socialAccount: SocialAccount::factory()->make(),
|
|
|
|
|
title: 'Hello',
|
|
|
|
|
body: 'World',
|
|
|
|
|
imageKeywords: ['kitchen'],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect($result)->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 16:38:30 +00:00
|
|
|
test('renders a slide and stores webp when AI returns bytes', function () use ($minimalPng) {
|
|
|
|
|
Image::fake([base64_encode($minimalPng())]);
|
2026-05-03 12:36:50 +00:00
|
|
|
|
|
|
|
|
if (! file_exists(base_path('resources/fonts/Inter-Bold.ttf'))) {
|
2026-05-08 16:38:30 +00:00
|
|
|
$this->markTestSkipped('Inter fonts not available — skipping render-dependent test.');
|
2026-05-03 12:36:50 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-08 16:38:30 +00:00
|
|
|
$service = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
2026-05-03 12:36:50 +00:00
|
|
|
$result = $service->render(
|
|
|
|
|
workspace: Workspace::factory()->make([
|
2026-05-08 16:38:30 +00:00
|
|
|
'image_style' => 'illustration',
|
2026-05-03 12:36:50 +00:00
|
|
|
'brand_color' => '#0000ff',
|
|
|
|
|
'background_color' => '#ffffff',
|
|
|
|
|
'text_color' => '#000000',
|
|
|
|
|
]),
|
|
|
|
|
socialAccount: SocialAccount::factory()->make([
|
|
|
|
|
'username' => 'testuser',
|
|
|
|
|
'display_name' => 'Test User',
|
|
|
|
|
]),
|
|
|
|
|
title: 'Hello World',
|
|
|
|
|
body: 'This is a test slide body.',
|
2026-05-08 16:38:30 +00:00
|
|
|
imageKeywords: ['kitchen', 'morning'],
|
2026-05-03 12:36:50 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if ($result !== null) {
|
2026-05-08 22:32:51 +00:00
|
|
|
expect($result['path'])->toStartWith('ai-images/')->toEndWith('.webp');
|
|
|
|
|
expect($result['source_meta'])
|
2026-05-08 16:38:30 +00:00
|
|
|
->toHaveKey('keywords')
|
|
|
|
|
->toHaveKey('style', 'illustration')
|
|
|
|
|
->toHaveKey('model', 'gpt-image-2')
|
2026-05-21 19:27:55 +00:00
|
|
|
->toHaveKey('title', 'Hello World')
|
|
|
|
|
->toHaveKey('brand_color', '#0000ff')
|
|
|
|
|
->toHaveKey('background_color', '#ffffff')
|
2026-05-21 22:52:12 +00:00
|
|
|
->toHaveKey('text_color', '#000000')
|
|
|
|
|
->toHaveKey('background_path');
|
2026-05-03 12:36:50 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-21 19:27:55 +00:00
|
|
|
Image::assertGenerated(fn ($prompt) => $prompt->contains('kitchen')
|
|
|
|
|
&& $prompt->contains('BRAND COLOR PALETTE')
|
|
|
|
|
&& $prompt->contains('blue'));
|
2026-05-08 16:38:30 +00:00
|
|
|
})->skip(fn () => ! extension_loaded('gd'), 'GD extension required');
|
2026-06-12 20:09:39 +00:00
|
|
|
|
|
|
|
|
test('omits the brand colour palette from the AI image prompt when brand visuals are off', function () use ($minimalPng) {
|
|
|
|
|
Image::fake([base64_encode($minimalPng())]);
|
|
|
|
|
|
|
|
|
|
if (! file_exists(base_path('resources/fonts/Inter-Bold.ttf'))) {
|
|
|
|
|
$this->markTestSkipped('Inter fonts not available — skipping render-dependent test.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$service = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
|
|
|
|
$service->render(
|
|
|
|
|
workspace: Workspace::factory()->make([
|
|
|
|
|
'brand_color' => '#0000ff',
|
|
|
|
|
'background_color' => '#ffffff',
|
|
|
|
|
'text_color' => '#000000',
|
|
|
|
|
]),
|
|
|
|
|
socialAccount: SocialAccount::factory()->make(['username' => 'u', 'display_name' => 'U']),
|
|
|
|
|
title: 'Hello',
|
|
|
|
|
body: 'Body',
|
|
|
|
|
imageKeywords: ['kitchen'],
|
|
|
|
|
applyBrandVisuals: false,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Despite the workspace having brand colours, the prompt must stay neutral.
|
|
|
|
|
Image::assertGenerated(fn ($prompt) => $prompt->contains('kitchen')
|
|
|
|
|
&& ! $prompt->contains('BRAND COLOR PALETTE'));
|
|
|
|
|
})->skip(fn () => ! extension_loaded('gd'), 'GD extension required');
|
2026-05-21 22:52:12 +00:00
|
|
|
|
|
|
|
|
test('reuses existing background path when provided', function () use ($minimalPng) {
|
|
|
|
|
if (! file_exists(base_path('resources/fonts/Inter-Bold.ttf'))) {
|
|
|
|
|
$this->markTestSkipped('Inter fonts not available — skipping render-dependent test.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$backgroundPath = 'ai-images/reuse-bg.webp';
|
|
|
|
|
Storage::put($backgroundPath, $minimalPng());
|
|
|
|
|
|
|
|
|
|
$service = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
|
|
|
|
$result = $service->render(
|
|
|
|
|
workspace: Workspace::factory()->make(),
|
|
|
|
|
socialAccount: SocialAccount::factory()->make(['username' => 'testuser', 'display_name' => 'Test User']),
|
|
|
|
|
title: 'Updated title',
|
|
|
|
|
body: 'Updated body',
|
|
|
|
|
imageKeywords: [],
|
|
|
|
|
backgroundPath: $backgroundPath,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect($result)->not->toBeNull();
|
|
|
|
|
expect(data_get($result, 'source_meta.background_path'))->toBe($backgroundPath);
|
|
|
|
|
Image::assertNothingGenerated();
|
|
|
|
|
})->skip(fn () => ! extension_loaded('gd'), 'GD extension required');
|