From de4d5f3c6802c19577da8a28ce139067aa6bf907 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 3 Jul 2026 19:28:28 -0300 Subject: [PATCH] Share a ContentLanguageOption type and close the review's test gaps - Extract ContentLanguageOption into @/types and use it for availableContentLanguages across BrandForm, BrandTab, Brand, Create, and the LanguagePicker options, so the englishName field that drives search is visible to TypeScript instead of being dropped silently by the pass-through prop types. - Add BrandAnalyzerTest: assert the language schema enum equals the full 15-language set (guards against it shrinking back to a hardcoded subset behind ::fake()) and that instructions() lists every code. - Isolate the "LLM language wins" autofill test by declaring the page as `en` while the LLM returns `de`, so it actually proves mergeLlm precedence instead of both paths agreeing. - Cover SetLocale's cookie side effect: the default locale cookie is set on an invalid/absent cookie and left untouched for a valid locale. --- resources/js/components/BrandForm.vue | 3 +- resources/js/components/LanguagePicker.vue | 3 +- resources/js/components/settings/BrandTab.vue | 3 +- .../js/pages/settings/workspace/Brand.vue | 3 +- resources/js/pages/workspaces/Create.vue | 3 +- resources/js/types/index.d.ts | 6 ++++ tests/Feature/Ai/AutofillBrandTest.php | 13 +++++---- tests/Feature/Middleware/SetLocaleTest.php | 28 +++++++++++++++++-- tests/Unit/Ai/Agents/BrandAnalyzerTest.php | 23 +++++++++++++++ 9 files changed, 72 insertions(+), 13 deletions(-) create mode 100644 tests/Unit/Ai/Agents/BrandAnalyzerTest.php 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}`"); + } +});