fix(ai-templates): paragraph spacing bug, registry cache, coverage gaps, empty-account UX
This commit is contained in:
parent
24d9653f6e
commit
bb55e14423
10 changed files with 60 additions and 5 deletions
|
|
@ -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> */
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue