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.
This commit is contained in:
Paulo Castellano 2026-07-03 19:28:28 -03:00
parent 5a96bb490a
commit de4d5f3c68
9 changed files with 72 additions and 13 deletions

View file

@ -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<string, string[]>;
availableContentLanguages: { value: string; label: string }[];
availableContentLanguages: ContentLanguageOption[];
autofill?: boolean;
showName?: boolean;
}>(),

View file

@ -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;

View file

@ -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<string, string[]>;
availableContentLanguages: { value: string; label: string }[];
availableContentLanguages: ContentLanguageOption[];
}>();
const form = useForm({

View file

@ -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<string, string[]>;
availableContentLanguages: { value: string; label: string }[];
availableContentLanguages: ContentLanguageOption[];
}>();
const tabs = computed(() => [

View file

@ -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<string, string[]>;
availableContentLanguages: { value: string; label: string }[];
availableContentLanguages: ContentLanguageOption[];
}>();
const form = useForm({

View file

@ -97,3 +97,9 @@ export interface PinterestBoard {
name: string;
}
export interface ContentLanguageOption {
value: string;
label: string;
englishName?: string;
}

View file

@ -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 <html lang> 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'
<html lang="de">
<html lang="en">
<head>
<title>Beispiel GmbH</title>
<meta name="description" content="Kurze Beschreibung.">
</head>
<body><main><p>Wir bauen Widgets.</p></main></body>
<body><main><p>Wir bauen Widgets für kleine Teams.</p></main></body>
</html>
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');
});

View file

@ -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 `<html dir>`.
* 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 `<html dir>`. */
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();
});

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use App\Ai\Agents\BrandAnalyzer;
use App\Enums\Workspace\ContentLanguage;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
test('language schema allows every supported content language', function () {
$schema = (new BrandAnalyzer)->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}`");
}
});