fix(ai-templates): paragraph spacing bug, registry cache, coverage gaps, empty-account UX

This commit is contained in:
Paulo Castellano 2026-06-17 18:09:02 -03:00
parent 24d9653f6e
commit bb55e14423
10 changed files with 60 additions and 5 deletions

View file

@ -15,10 +15,13 @@ class AiTemplateRegistry
TweetCardTemplate::class,
];
/** @var array<int, AiContentTemplate>|null */
private ?array $templates = null;
/** @return array<int, AiContentTemplate> */
public function all(): array
{
return array_map(fn (string $class) => app($class), self::TEMPLATES);
return $this->templates ??= array_map(fn (string $class) => app($class), self::TEMPLATES);
}
/** @return array<int, string> */

View file

@ -9,6 +9,7 @@
use App\Actions\Post\DuplicatePost;
use App\Actions\Post\SyncPostPlatforms;
use App\Actions\Post\UpdatePost;
use App\Ai\Templates\AiContentTemplate;
use App\Ai\Templates\AiTemplateRegistry;
use App\Enums\Post\Action as PostAction;
use App\Enums\Post\Status as PostStatus;
@ -145,7 +146,7 @@ public function create(Request $request): Response
$registry = app(AiTemplateRegistry::class);
$templates = array_map(fn ($t) => [
$templates = array_map(fn (AiContentTemplate $t) => [
'key' => $t->key(),
'name' => trans($t->name()),
'description' => trans($t->description()),

View file

@ -590,7 +590,7 @@ public function renderTweetCard(Workspace $workspace, SocialAccount $socialAccou
$bodyLineHeight = 1.55;
$paragraphGap = (int) round($bodySize * $bodyLineHeight * 0.6);
$paragraphs = array_filter(array_map('trim', explode("\n\n", $tweetText)), fn ($p) => $p !== '');
$paragraphs = array_values(array_filter(array_map('trim', explode("\n\n", $tweetText)), fn ($p) => $p !== ''));
$allBodyLines = [];
foreach ($paragraphs as $i => $para) {
$lines = ($fontMedium ? $this->wrapText($para, $fontMedium, $bodySize, $cardW - 2 * $cardPadding) : [str_replace("\n", ' ', $para)]);

View file

@ -620,6 +620,7 @@
'retry' => 'Try again',
'no_platforms' => 'No connected accounts',
'connect_first' => 'Connect at least one social account to use AI generation.',
'no_account_for_template' => 'Connect a compatible account to use this template.',
'format' => [
'instagram_feed' => 'Instagram Feed Post',

View file

@ -621,6 +621,7 @@
'retry' => 'Intentar de nuevo',
'no_platforms' => 'Sin cuentas conectadas',
'connect_first' => 'Conecta al menos una cuenta social para usar la generación con IA.',
'no_account_for_template' => 'Conecta una cuenta compatible para usar esta plantilla.',
'format' => [
'instagram_feed' => 'Post de Feed de Instagram',

View file

@ -620,6 +620,7 @@
'retry' => 'Tentar novamente',
'no_platforms' => 'Nenhuma conta conectada',
'connect_first' => 'Conecte pelo menos uma conta social para usar a geração com IA.',
'no_account_for_template' => 'Conecte uma conta compatível para usar este template.',
'format' => [
'instagram_feed' => 'Post no Feed do Instagram',

View file

@ -337,7 +337,10 @@ const startGeneration = async () => {
<!-- Account (when template needs_account OR there's a choice to make) -->
<div v-if="selectedFormat && (templateNeedsAccount || showsAccountPicker)" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('posts.create.steps.account_title') }}</Label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<p v-if="templateNeedsAccount && accountsForFormat.length === 0" class="text-sm text-foreground/60">
{{ $t('posts.create.steps.no_account_for_template') }}
</p>
<div v-else class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<button
v-for="account in accountsForFormat"
:key="account.id"

View file

@ -117,6 +117,8 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag
$platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();
expect($platform->content_type)->toBe(ContentType::XPost);
PostContentHumanizer::assertNeverPrompted();
});
test('the humanizer is given the same platform context as the generator so the rewrite honours the character cap', function () {

View file

@ -7,7 +7,7 @@
test('registry resolves keys and defaults to image_card', function () {
$registry = app(AiTemplateRegistry::class);
expect($registry->keys())->toContain('image_card', 'carousel')
expect($registry->keys())->toContain('image_card', 'carousel', 'tweet_card')
->and($registry->find('image_card'))->toBeInstanceOf(ImageCardTemplate::class)
->and($registry->default()->key())->toBe('image_card');
});

View file

@ -4,11 +4,14 @@
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Image\PostImagePipeline;
use App\Services\Image\TemplateImageGenerator;
use Illuminate\Support\Facades\Storage;
use Laravel\Ai\Image;
test('renderTweetCard produces a stored webp without calling the AI image client', function () {
Storage::fake();
Image::fake();
$workspace = Workspace::factory()->create(['brand_color' => '#1d9bf0']);
$account = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
@ -26,4 +29,44 @@
->and($result['path'])->toEndWith('.webp')
->and(Storage::exists($result['path']))->toBeTrue()
->and(data_get($result, 'source_meta.template'))->toBe('tweet_card');
Image::assertNothingGenerated();
});
test('renderTweetCard succeeds when the account has no avatar_url', function () {
Storage::fake();
Image::fake();
$workspace = Workspace::factory()->create(['brand_color' => '#1d9bf0']);
$account = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'display_name' => 'No Avatar User',
'username' => 'noavatar',
'avatar_url' => null,
]);
$result = app(TemplateImageGenerator::class)->renderTweetCard(
$workspace,
$account,
'A tweet with no avatar attached.',
);
expect($result)->not->toBeNull()
->and($result['path'])->toEndWith('.webp')
->and(Storage::exists($result['path']))->toBeTrue();
});
test('forTweetCard returns [] when renderTweetCard returns null', function () {
Storage::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->create(['workspace_id' => $workspace->id]);
$mock = Mockery::mock(TemplateImageGenerator::class)->makePartial();
$mock->shouldReceive('renderTweetCard')->andReturn(null);
$this->app->instance(TemplateImageGenerator::class, $mock);
$result = app(PostImagePipeline::class)->forTweetCard($workspace, $account, 'some text');
expect($result)->toBe([]);
});