diff --git a/resources/js/components/BrandForm.vue b/resources/js/components/BrandForm.vue index b4e99f7a..87738d34 100644 --- a/resources/js/components/BrandForm.vue +++ b/resources/js/components/BrandForm.vue @@ -22,6 +22,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { autofill as autofillBrand } from '@/routes/app/workspaces'; +import type { ContentLanguageOption } from '@/types'; interface BrandFields { name?: string; @@ -55,7 +56,7 @@ const props = withDefaults( availableFonts: string[]; availableImageStyles: string[]; availableVoiceTraits: Record; - availableContentLanguages: { value: string; label: string }[]; + availableContentLanguages: ContentLanguageOption[]; autofill?: boolean; showName?: boolean; }>(), diff --git a/resources/js/components/LanguagePicker.vue b/resources/js/components/LanguagePicker.vue index 5e6e2d3d..d8dd6e59 100644 --- a/resources/js/components/LanguagePicker.vue +++ b/resources/js/components/LanguagePicker.vue @@ -6,10 +6,11 @@ import { Button } from '@/components/ui/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; +import type { ContentLanguageOption } from '@/types'; interface Props { /** Selectable languages as { value: locale code, label: native name, englishName: name in English }. */ - options: { value: string; label: string; englishName?: string }[]; + options: ContentLanguageOption[]; placeholder?: string; searchPlaceholder?: string; emptyText?: string; diff --git a/resources/js/components/settings/BrandTab.vue b/resources/js/components/settings/BrandTab.vue index 276aa60e..dceecbb6 100644 --- a/resources/js/components/settings/BrandTab.vue +++ b/resources/js/components/settings/BrandTab.vue @@ -5,6 +5,7 @@ import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceCon import BrandForm from '@/components/BrandForm.vue'; import HeadingSmall from '@/components/HeadingSmall.vue'; import { Button } from '@/components/ui/button'; +import type { ContentLanguageOption } from '@/types'; interface Workspace { id: string; @@ -25,7 +26,7 @@ const props = defineProps<{ availableFonts: string[]; availableImageStyles: string[]; availableVoiceTraits: Record; - availableContentLanguages: { value: string; label: string }[]; + availableContentLanguages: ContentLanguageOption[]; }>(); const form = useForm({ diff --git a/resources/js/pages/settings/workspace/Brand.vue b/resources/js/pages/settings/workspace/Brand.vue index 7e29d74b..9e0c5808 100644 --- a/resources/js/pages/settings/workspace/Brand.vue +++ b/resources/js/pages/settings/workspace/Brand.vue @@ -10,6 +10,7 @@ import AppLayout from '@/layouts/AppLayout.vue'; import { members as membersRoute } from '@/routes/app'; import { index as apiKeysRoute } from '@/routes/app/api-keys'; import { brand as brandRoute, settings as workspaceSettings } from '@/routes/app/workspace'; +import type { ContentLanguageOption } from '@/types'; interface Workspace { id: string; @@ -30,7 +31,7 @@ defineProps<{ availableFonts: string[]; availableImageStyles: string[]; availableVoiceTraits: Record; - availableContentLanguages: { value: string; label: string }[]; + availableContentLanguages: ContentLanguageOption[]; }>(); const tabs = computed(() => [ diff --git a/resources/js/pages/workspaces/Create.vue b/resources/js/pages/workspaces/Create.vue index bb96ce62..dbd4c2b6 100644 --- a/resources/js/pages/workspaces/Create.vue +++ b/resources/js/pages/workspaces/Create.vue @@ -5,12 +5,13 @@ import BrandForm from '@/components/BrandForm.vue'; import { Button } from '@/components/ui/button'; import AuthLayout from '@/layouts/AuthLayout.vue'; import { store as storeWorkspace } from '@/routes/app/workspaces'; +import type { ContentLanguageOption } from '@/types'; defineProps<{ availableFonts: string[]; availableImageStyles: string[]; availableVoiceTraits: Record; - availableContentLanguages: { value: string; label: string }[]; + availableContentLanguages: ContentLanguageOption[]; }>(); const form = useForm({ diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index b1bf5001..e95a5037 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -97,3 +97,9 @@ export interface PinterestBoard { name: string; } +export interface ContentLanguageOption { + value: string; + label: string; + englishName?: string; +} + diff --git a/tests/Feature/Ai/AutofillBrandTest.php b/tests/Feature/Ai/AutofillBrandTest.php index 5f43f750..f0eec7c7 100644 --- a/tests/Feature/Ai/AutofillBrandTest.php +++ b/tests/Feature/Ai/AutofillBrandTest.php @@ -390,18 +390,23 @@ expect($result->toArray()['brand_voice_traits'])->toBe(['third_person', 'direct', 'no_hype']); }); -test('LLM language detection carries any supported language, not just en/es/pt-BR', function () { +test('LLM language detection wins and carries any supported language, not just en/es/pt-BR', function () { config()->set('services.gemini.api_key', 'fake-key'); config()->set('ai.default', 'gemini'); + // The declares "en", so the deterministic extractor yields 'en'. + // The LLM reads the actual German body and returns 'de'. Since the fixture's + // deterministic value differs from the LLM's, this isolates the mergeLlm + // precedence: the LLM value must win, and it must be a language beyond the + // original en/es/pt-BR set. Http::fake([ 'example.com' => Http::response(<<<'HTML' - + Beispiel GmbH -

Wir bauen Widgets.

+

Wir bauen Widgets für kleine Teams.

HTML, 200), ]); @@ -416,8 +421,6 @@ $result = ($this->autofill)('https://example.com'); - // The LLM's detected language wins over the deterministic one and must be - // able to be one of the 12 languages added beyond the original en/es/pt-BR. expect($result->language)->toBe('de'); }); diff --git a/tests/Feature/Middleware/SetLocaleTest.php b/tests/Feature/Middleware/SetLocaleTest.php index 8096c453..1da03f03 100644 --- a/tests/Feature/Middleware/SetLocaleTest.php +++ b/tests/Feature/Middleware/SetLocaleTest.php @@ -5,12 +5,13 @@ use App\Http\Middleware\App\SetLocale; use Illuminate\Http\Request; use Illuminate\Support\Facades\View; +use Symfony\Component\HttpFoundation\Response; /** * Run the middleware with the given `locale` cookie (null = no cookie) and - * return the shared `htmlDir` the Blade root view renders into ``. + * return the response, whose shared `htmlDir` and queued cookies can be asserted. */ -function runSetLocale(?string $locale): string +function setLocaleResponse(?string $locale): Response { $request = Request::create('/', 'GET'); @@ -18,7 +19,13 @@ function runSetLocale(?string $locale): string $request->cookies->set('locale', $locale); } - (new SetLocale)->handle($request, fn () => response('ok')); + return (new SetLocale)->handle($request, fn () => response('ok')); +} + +/** The shared `htmlDir` the Blade root view renders into ``. */ +function runSetLocale(?string $locale): string +{ + setLocaleResponse($locale); return View::shared('htmlDir'); } @@ -42,3 +49,18 @@ function runSetLocale(?string $locale): string expect(runSetLocale(null))->toBe('ltr'); expect(app()->getLocale())->toBe(config('languages.default')); }); + +test('persists the default locale cookie when the incoming cookie is invalid or absent', function (?string $locale) { + $cookie = collect(setLocaleResponse($locale)->headers->getCookies()) + ->first(fn ($cookie) => $cookie->getName() === 'locale'); + + expect($cookie)->not->toBeNull(); + expect($cookie->getValue())->toBe(config('languages.default')); +})->with(['sv', null]); + +test('does not reset the cookie when a valid locale is present', function () { + $cookie = collect(setLocaleResponse('ar')->headers->getCookies()) + ->first(fn ($cookie) => $cookie->getName() === 'locale'); + + expect($cookie)->toBeNull(); +}); diff --git a/tests/Unit/Ai/Agents/BrandAnalyzerTest.php b/tests/Unit/Ai/Agents/BrandAnalyzerTest.php new file mode 100644 index 00000000..10f9c71d --- /dev/null +++ b/tests/Unit/Ai/Agents/BrandAnalyzerTest.php @@ -0,0 +1,23 @@ +schema(new JsonSchemaTypeFactory); + + // Guards against the enum silently shrinking back to a hardcoded subset: + // the LLM may only emit a language the schema allows. + expect($schema['language']->toArray()['enum'])->toBe(ContentLanguage::values()); +}); + +test('instructions list every supported language code', function () { + $instructions = (new BrandAnalyzer)->instructions(); + + foreach (ContentLanguage::values() as $code) { + expect($instructions)->toContain("`{$code}`"); + } +});