feat(ai-templates): ContentStyle enum as the typed source for the 3 styles
This commit is contained in:
parent
d8291cb155
commit
0574560570
12 changed files with 170 additions and 30 deletions
|
|
@ -4,10 +4,14 @@
|
|||
|
||||
namespace App\Ai\Templates;
|
||||
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
|
||||
interface AiContentTemplate
|
||||
{
|
||||
/** The typed enum case for this style. */
|
||||
public function style(): ContentStyle;
|
||||
|
||||
/** Stable key used in the request + registry, e.g. 'image_card'. */
|
||||
public function key(): string;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Ai\Templates;
|
||||
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class AiTemplateRegistry
|
||||
|
|
@ -30,15 +31,23 @@ public function keys(): array
|
|||
return array_map(fn (AiContentTemplate $t) => $t->key(), $this->all());
|
||||
}
|
||||
|
||||
public function find(string $key): AiContentTemplate
|
||||
/** @return array<int, ContentStyle> */
|
||||
public function styles(): array
|
||||
{
|
||||
return ContentStyle::cases();
|
||||
}
|
||||
|
||||
public function find(ContentStyle|string $style): AiContentTemplate
|
||||
{
|
||||
$value = $style instanceof ContentStyle ? $style->value : $style;
|
||||
|
||||
foreach ($this->all() as $template) {
|
||||
if ($template->key() === $key) {
|
||||
if ($template->key() === $value) {
|
||||
return $template;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException("Unknown AI content template: {$key}");
|
||||
throw new InvalidArgumentException("Unknown AI content template: {$value}");
|
||||
}
|
||||
|
||||
public function default(): AiContentTemplate
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
namespace App\Ai\Templates;
|
||||
|
||||
use App\Ai\Templates\Concerns\ResolvesContentType;
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Services\Image\PostImagePipeline;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
|
|
@ -13,29 +14,34 @@ class ImageCardTemplate implements AiContentTemplate
|
|||
{
|
||||
use ResolvesContentType;
|
||||
|
||||
public function style(): ContentStyle
|
||||
{
|
||||
return ContentStyle::ImageCard;
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'image_card';
|
||||
return $this->style()->value;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'posts.ai.templates.image_card.name';
|
||||
return $this->style()->label();
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'posts.ai.templates.image_card.description';
|
||||
return $this->style()->description();
|
||||
}
|
||||
|
||||
public function previewAsset(): string
|
||||
{
|
||||
return '/images/ai-templates/image-card.png';
|
||||
return $this->style()->previewAsset();
|
||||
}
|
||||
|
||||
public function needsAccount(): bool
|
||||
{
|
||||
return false;
|
||||
return $this->style()->needsAccount();
|
||||
}
|
||||
|
||||
public function supportedFormats(): array
|
||||
|
|
|
|||
|
|
@ -4,35 +4,41 @@
|
|||
|
||||
namespace App\Ai\Templates;
|
||||
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Services\Image\PostImagePipeline;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
|
||||
class TweetCardImageTemplate implements AiContentTemplate
|
||||
{
|
||||
public function style(): ContentStyle
|
||||
{
|
||||
return ContentStyle::TweetCardImage;
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'tweet_card_image';
|
||||
return $this->style()->value;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'posts.ai.templates.tweet_card_image.name';
|
||||
return $this->style()->label();
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'posts.ai.templates.tweet_card_image.description';
|
||||
return $this->style()->description();
|
||||
}
|
||||
|
||||
public function previewAsset(): string
|
||||
{
|
||||
return '/images/ai-templates/tweet-card-image.png';
|
||||
return $this->style()->previewAsset();
|
||||
}
|
||||
|
||||
public function needsAccount(): bool
|
||||
{
|
||||
return true;
|
||||
return $this->style()->needsAccount();
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
|
|
|
|||
|
|
@ -4,35 +4,41 @@
|
|||
|
||||
namespace App\Ai\Templates;
|
||||
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Services\Image\PostImagePipeline;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
|
||||
class TweetCardTemplate implements AiContentTemplate
|
||||
{
|
||||
public function style(): ContentStyle
|
||||
{
|
||||
return ContentStyle::TweetCard;
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'tweet_card';
|
||||
return $this->style()->value;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'posts.ai.templates.tweet_card.name';
|
||||
return $this->style()->label();
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'posts.ai.templates.tweet_card.description';
|
||||
return $this->style()->description();
|
||||
}
|
||||
|
||||
public function previewAsset(): string
|
||||
{
|
||||
return '/images/ai-templates/tweet-card.png';
|
||||
return $this->style()->previewAsset();
|
||||
}
|
||||
|
||||
public function needsAccount(): bool
|
||||
{
|
||||
return true;
|
||||
return $this->style()->needsAccount();
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
|
|
|
|||
48
app/Enums/Ai/ContentStyle.php
Normal file
48
app/Enums/Ai/ContentStyle.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Ai;
|
||||
|
||||
enum ContentStyle: string
|
||||
{
|
||||
case ImageCard = 'image_card';
|
||||
case TweetCard = 'tweet_card';
|
||||
case TweetCardImage = 'tweet_card_image';
|
||||
|
||||
/** i18n key for the style's display name. */
|
||||
public function label(): string
|
||||
{
|
||||
return "posts.ai.templates.{$this->value}.name";
|
||||
}
|
||||
|
||||
/** i18n key for the style's description. */
|
||||
public function description(): string
|
||||
{
|
||||
return "posts.ai.templates.{$this->value}.description";
|
||||
}
|
||||
|
||||
/** Whether this style requires a connected social account. */
|
||||
public function needsAccount(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::ImageCard => false,
|
||||
self::TweetCard, self::TweetCardImage => true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Public path to the picker preview thumbnail. */
|
||||
public function previewAsset(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ImageCard => '/images/ai-templates/image-card.png',
|
||||
self::TweetCard => '/images/ai-templates/tweet-card.png',
|
||||
self::TweetCardImage => '/images/ai-templates/tweet-card-image.png',
|
||||
};
|
||||
}
|
||||
|
||||
public static function default(): self
|
||||
{
|
||||
return self::ImageCard;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
namespace App\Http\Requests\App\Ai;
|
||||
|
||||
use App\Ai\Templates\AiTemplateRegistry;
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -35,21 +35,17 @@ public function rules(): array
|
|||
'image_count' => ['nullable', 'integer', 'min:0', 'max:10'],
|
||||
'prompt' => ['required', 'string', 'max:2000'],
|
||||
'date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'template' => ['sometimes', 'string', Rule::in(app(AiTemplateRegistry::class)->keys())],
|
||||
'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$key = (string) $this->input('template', 'image_card');
|
||||
$registry = app(AiTemplateRegistry::class);
|
||||
$style = ContentStyle::tryFrom((string) $this->input('template', ContentStyle::default()->value))
|
||||
?? ContentStyle::default();
|
||||
|
||||
if (! in_array($key, $registry->keys(), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($registry->find($key)->needsAccount() && blank($this->input('social_account_id'))) {
|
||||
if ($style->needsAccount() && blank($this->input('social_account_id'))) {
|
||||
$validator->errors()->add('social_account_id', trans('validation.required', ['attribute' => 'social account']));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
use App\Ai\Templates\AiTemplateRegistry;
|
||||
use App\Ai\Templates\ImageCardTemplate;
|
||||
use App\Ai\Templates\TweetCardImageTemplate;
|
||||
use App\Ai\Templates\TweetCardTemplate;
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
|
||||
test('registry resolves keys and defaults to image_card', function () {
|
||||
$registry = app(AiTemplateRegistry::class);
|
||||
|
|
@ -14,6 +16,23 @@
|
|||
->and($registry->default()->key())->toBe('image_card');
|
||||
});
|
||||
|
||||
test('registry find accepts a ContentStyle enum', function () {
|
||||
$registry = app(AiTemplateRegistry::class);
|
||||
expect($registry->find(ContentStyle::TweetCard))->toBeInstanceOf(TweetCardTemplate::class)
|
||||
->and($registry->find(ContentStyle::ImageCard))->toBeInstanceOf(ImageCardTemplate::class)
|
||||
->and($registry->find(ContentStyle::TweetCardImage))->toBeInstanceOf(TweetCardImageTemplate::class);
|
||||
});
|
||||
|
||||
test('registry find accepts a string key', function () {
|
||||
$registry = app(AiTemplateRegistry::class);
|
||||
expect($registry->find('tweet_card'))->toBeInstanceOf(TweetCardTemplate::class);
|
||||
});
|
||||
|
||||
test('registry styles returns all ContentStyle cases', function () {
|
||||
$registry = app(AiTemplateRegistry::class);
|
||||
expect($registry->styles())->toBe(ContentStyle::cases());
|
||||
});
|
||||
|
||||
test('registry throws on unknown key', function () {
|
||||
expect(fn () => app(AiTemplateRegistry::class)->find('nope'))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Ai\Templates\ImageCardTemplate;
|
||||
use App\Ai\Templates\TemplateContext;
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
|
||||
|
||||
|
|
@ -12,7 +13,8 @@
|
|||
$workspace = Workspace::factory()->create();
|
||||
$context = new TemplateContext($workspace, null, 'instagram_feed', 0, false);
|
||||
|
||||
expect($t->key())->toBe('image_card')
|
||||
expect($t->style())->toBe(ContentStyle::ImageCard)
|
||||
->and($t->key())->toBe('image_card')
|
||||
->and($t->needsAccount())->toBeFalse()
|
||||
->and($t->generatorFormat())->toBe('single')
|
||||
->and($t->promptView($context))->toBe('prompts.post_content.generator')
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Ai\Templates\TemplateContext;
|
||||
use App\Ai\Templates\TweetCardImageTemplate;
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
|
||||
|
||||
|
|
@ -13,7 +14,8 @@
|
|||
$singleContext = new TemplateContext($workspace, null, 'x_post', 1, false);
|
||||
$carouselContext = new TemplateContext($workspace, null, 'instagram_carousel', 2, true);
|
||||
|
||||
expect($t->key())->toBe('tweet_card_image')
|
||||
expect($t->style())->toBe(ContentStyle::TweetCardImage)
|
||||
->and($t->key())->toBe('tweet_card_image')
|
||||
->and($t->needsAccount())->toBeTrue()
|
||||
->and($t->generatorFormat())->toBe('tweet_card')
|
||||
->and($t->promptView($singleContext))->toBe('prompts.post_content.tweet_card_image')
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Ai\Templates\TemplateContext;
|
||||
use App\Ai\Templates\TweetCardTemplate;
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
|
||||
|
||||
|
|
@ -13,7 +14,8 @@
|
|||
$singleContext = new TemplateContext($workspace, null, 'x_post', 1, false);
|
||||
$carouselContext = new TemplateContext($workspace, null, 'instagram_carousel', 2, true);
|
||||
|
||||
expect($t->key())->toBe('tweet_card')
|
||||
expect($t->style())->toBe(ContentStyle::TweetCard)
|
||||
->and($t->key())->toBe('tweet_card')
|
||||
->and($t->needsAccount())->toBeTrue()
|
||||
->and($t->generatorFormat())->toBe('tweet_card')
|
||||
->and($t->promptView($singleContext))->toBe('prompts.post_content.tweet_card')
|
||||
|
|
|
|||
40
tests/Unit/Enums/ContentStyleTest.php
Normal file
40
tests/Unit/Enums/ContentStyleTest.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
|
||||
test('enum has exactly 3 cases with the correct string values', function () {
|
||||
expect(ContentStyle::cases())->toHaveCount(3);
|
||||
expect(ContentStyle::ImageCard->value)->toBe('image_card');
|
||||
expect(ContentStyle::TweetCard->value)->toBe('tweet_card');
|
||||
expect(ContentStyle::TweetCardImage->value)->toBe('tweet_card_image');
|
||||
});
|
||||
|
||||
test('default returns ImageCard', function () {
|
||||
expect(ContentStyle::default())->toBe(ContentStyle::ImageCard);
|
||||
});
|
||||
|
||||
test('needsAccount is false only for ImageCard', function () {
|
||||
expect(ContentStyle::ImageCard->needsAccount())->toBeFalse();
|
||||
expect(ContentStyle::TweetCard->needsAccount())->toBeTrue();
|
||||
expect(ContentStyle::TweetCardImage->needsAccount())->toBeTrue();
|
||||
});
|
||||
|
||||
test('previewAsset returns the correct public path for each case', function () {
|
||||
expect(ContentStyle::ImageCard->previewAsset())->toBe('/images/ai-templates/image-card.png');
|
||||
expect(ContentStyle::TweetCard->previewAsset())->toBe('/images/ai-templates/tweet-card.png');
|
||||
expect(ContentStyle::TweetCardImage->previewAsset())->toBe('/images/ai-templates/tweet-card-image.png');
|
||||
});
|
||||
|
||||
test('label returns the expected i18n key format', function () {
|
||||
expect(ContentStyle::ImageCard->label())->toBe('posts.ai.templates.image_card.name');
|
||||
expect(ContentStyle::TweetCard->label())->toBe('posts.ai.templates.tweet_card.name');
|
||||
expect(ContentStyle::TweetCardImage->label())->toBe('posts.ai.templates.tweet_card_image.name');
|
||||
});
|
||||
|
||||
test('description returns the expected i18n key format', function () {
|
||||
expect(ContentStyle::ImageCard->description())->toBe('posts.ai.templates.image_card.description');
|
||||
expect(ContentStyle::TweetCard->description())->toBe('posts.ai.templates.tweet_card.description');
|
||||
expect(ContentStyle::TweetCardImage->description())->toBe('posts.ai.templates.tweet_card_image.description');
|
||||
});
|
||||
Loading…
Reference in a new issue