diff --git a/api/openapi.yaml b/api/openapi.yaml index 41a3153..4e34c8b 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -458,6 +458,11 @@ components: # ------------------------- schemas: + Language: + type: string + enum: [EN, DE, HU] + description: Supported UI and AI-content language as an ISO 639-1 code + ErrorResponse: type: object required: [message] @@ -524,8 +529,7 @@ components: additionalProperties: false properties: language: - type: string - enum: [EN, DE, HU] + $ref: '#/components/schemas/Language' description: Preferred UI and AI-content language as an ISO 639-1 code diet: type: array @@ -677,6 +681,9 @@ components: type: string minLength: 1 maxLength: 4096 + language: + $ref: '#/components/schemas/Language' + description: Active UI language; generated recipe content is written in it HelpRequest: type: object diff --git a/openapitools.json b/openapitools.json index c27f4c1..6707fd3 100644 --- a/openapitools.json +++ b/openapitools.json @@ -1,3 +1,3 @@ { - "generator-cli": { "version": "7.21.0" } + "generator-cli": { "version": "7.22.0" } } diff --git a/services/py-help-service/client/pyproject.toml b/services/py-help-service/client/pyproject.toml index bd4e875..d51d2d9 100644 --- a/services/py-help-service/client/pyproject.toml +++ b/services/py-help-service/client/pyproject.toml @@ -10,10 +10,9 @@ packages = [ include = ["cooking_assistant_gen_ai_services_api_internal_client/py.typed"] [tool.poetry.dependencies] -python = "^3.10" -httpx = ">=0.23.0,<0.29.0" +python = "^3.11" +httpx = ">=0.23.1,<0.29.0" attrs = ">=22.2.0" -python-dateutil = "^2.8.0" [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] diff --git a/services/py-recipe-service/client/pyproject.toml b/services/py-recipe-service/client/pyproject.toml index bd4e875..d51d2d9 100644 --- a/services/py-recipe-service/client/pyproject.toml +++ b/services/py-recipe-service/client/pyproject.toml @@ -10,10 +10,9 @@ packages = [ include = ["cooking_assistant_gen_ai_services_api_internal_client/py.typed"] [tool.poetry.dependencies] -python = "^3.10" -httpx = ">=0.23.0,<0.29.0" +python = "^3.11" +httpx = ">=0.23.1,<0.29.0" attrs = ">=22.2.0" -python-dateutil = "^2.8.0" [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] diff --git a/services/spring-api/.openapi-generator/FILES b/services/spring-api/.openapi-generator/FILES index 3015fe8..e4d3337 100644 --- a/services/spring-api/.openapi-generator/FILES +++ b/services/spring-api/.openapi-generator/FILES @@ -10,11 +10,13 @@ src/main/kotlin/org/openapitools/api/ApiUtil.kt src/main/kotlin/org/openapitools/api/Exceptions.kt src/main/kotlin/org/openapitools/api/RecipesApi.kt src/main/kotlin/org/openapitools/api/UsersApi.kt +src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt src/main/kotlin/org/openapitools/model/AuthRequest.kt src/main/kotlin/org/openapitools/model/AuthResponse.kt src/main/kotlin/org/openapitools/model/ErrorResponse.kt src/main/kotlin/org/openapitools/model/HelpRequest.kt src/main/kotlin/org/openapitools/model/HelpResponse.kt +src/main/kotlin/org/openapitools/model/Language.kt src/main/kotlin/org/openapitools/model/Recipe.kt src/main/kotlin/org/openapitools/model/RecipeCreated.kt src/main/kotlin/org/openapitools/model/RecipeIngredient.kt diff --git a/services/spring-api/src/main/kotlin/org/openapitools/api/AIApiController.kt b/services/spring-api/src/main/kotlin/org/openapitools/api/AIApiController.kt index e8b77d3..4c7304a 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/api/AIApiController.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/api/AIApiController.kt @@ -63,9 +63,24 @@ class AIApiController( ): ResponseEntity> { val user = userRepository.findByUsername(currentUsername()).orElseThrow() + // the client sends the active UI language so generated recipes match what the user sees, + // even when no language is stored in their preferences + val profile = user.toInternalProfile() + val profileWithLanguage = + recipeRequest.language?.let { lang -> + profile.copy( + preferences = + profile.preferences.copy( + language = + org.openapitools.internal.model.UserPreferences.Language + .valueOf(lang.name), + ), + ) + } ?: profile + val internalRequest = org.openapitools.internal.model.RecipeRequestForwarded( - profile = user.toInternalProfile(), + profile = profileWithLanguage, prompt = recipeRequest.prompt, ) diff --git a/services/spring-api/src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt b/services/spring-api/src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt new file mode 100644 index 0000000..c4cf556 --- /dev/null +++ b/services/spring-api/src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt @@ -0,0 +1,22 @@ +package org.openapitools.configuration + +import org.openapitools.model.Language +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.convert.converter.Converter + +/** + * This class provides Spring Converter beans for the enum models in the OpenAPI specification. + * + * By default, Spring only converts primitive types to enums using Enum::valueOf, which can prevent + * correct conversion if the OpenAPI specification is using an `enumPropertyNaming` other than + * `original` or the specification has an integer enum. + */ +@Configuration(value = "org.openapitools.configuration.enumConverterConfiguration") +class EnumConverterConfiguration { + @Bean(name = ["org.openapitools.configuration.EnumConverterConfiguration.languageConverter"]) + fun languageConverter(): Converter = + object : Converter { + override fun convert(source: kotlin.String): Language = Language.forValue(source) + } +} diff --git a/services/spring-api/src/main/kotlin/org/openapitools/model/Language.kt b/services/spring-api/src/main/kotlin/org/openapitools/model/Language.kt new file mode 100644 index 0000000..9c77019 --- /dev/null +++ b/services/spring-api/src/main/kotlin/org/openapitools/model/Language.kt @@ -0,0 +1,37 @@ +package org.openapitools.model + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonValue +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import java.util.Objects + +/** +* Supported UI and AI-content language as an ISO 639-1 code +* Values: EN,DE,HU +*/ +enum class Language( + @get:JsonValue val value: kotlin.String, +) { + EN("EN"), + DE("DE"), + HU("HU"), + ; + + companion object { + @JvmStatic + @JsonCreator + fun forValue(value: kotlin.String): Language = + values().firstOrNull { it -> it.value == value } + ?: throw IllegalArgumentException("Unexpected value '$value' for enum 'Language'") + } +} diff --git a/services/spring-api/src/main/kotlin/org/openapitools/model/RecipeRequest.kt b/services/spring-api/src/main/kotlin/org/openapitools/model/RecipeRequest.kt index 3cb45f0..b0ae000 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/model/RecipeRequest.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/model/RecipeRequest.kt @@ -1,6 +1,8 @@ package org.openapitools.model +import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonValue import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.Valid import jakarta.validation.constraints.DecimalMax @@ -11,14 +13,19 @@ import jakarta.validation.constraints.Min import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size +import org.openapitools.model.Language import java.util.Objects /** * * @param prompt + * @param language Active UI language; generated recipe content is written in it */ data class RecipeRequest( @get:Size(min = 1, max = 4096) @Schema(example = "null", required = true, description = "") @get:JsonProperty("prompt", required = true) val prompt: kotlin.String, + @field:Valid + @Schema(example = "null", description = "Active UI language; generated recipe content is written in it") + @get:JsonProperty("language") val language: Language? = null, ) diff --git a/services/spring-api/src/main/kotlin/org/openapitools/model/UserPreferences.kt b/services/spring-api/src/main/kotlin/org/openapitools/model/UserPreferences.kt index 6732fdb..0be8a36 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/model/UserPreferences.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/model/UserPreferences.kt @@ -13,6 +13,7 @@ import jakarta.validation.constraints.Min import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Pattern import jakarta.validation.constraints.Size +import org.openapitools.model.Language import java.util.Objects /** @@ -23,33 +24,13 @@ import java.util.Objects * @param aboutMe Free-form user context provided to the AI */ data class UserPreferences( + @field:Valid @Schema(example = "null", description = "Preferred UI and AI-content language as an ISO 639-1 code") - @get:JsonProperty("language") val language: UserPreferences.Language? = null, + @get:JsonProperty("language") val language: Language? = null, @Schema(example = "null", description = "Dietary restriction or style (e.g. vegan, keto)") @get:JsonProperty("diet") val diet: kotlin.collections.List? = null, @Schema(example = "null", description = "List of ingredients the user is allergic to") @get:JsonProperty("allergies") val allergies: kotlin.collections.List? = null, @Schema(example = "null", description = "Free-form user context provided to the AI") @get:JsonProperty("aboutMe") val aboutMe: kotlin.collections.List? = null, -) { - /** - * Preferred UI and AI-content language as an ISO 639-1 code - * Values: EN,DE,HU - */ - enum class Language( - @get:JsonValue val value: kotlin.String, - ) { - EN("EN"), - DE("DE"), - HU("HU"), - ; - - companion object { - @JvmStatic - @JsonCreator - fun forValue(value: kotlin.String): Language = - values().firstOrNull { it -> it.value == value } - ?: throw IllegalArgumentException("Unexpected value '$value' for enum 'Language'") - } - } -} +) diff --git a/services/spring-api/src/main/resources/openapi.yaml b/services/spring-api/src/main/resources/openapi.yaml index 1bd4eca..50e3e87 100644 --- a/services/spring-api/src/main/resources/openapi.yaml +++ b/services/spring-api/src/main/resources/openapi.yaml @@ -568,6 +568,10 @@ components: type: string minLength: 1 maxLength: 2000 + language: + type: string + enum: [EN, DE, HU] + description: Active UI language; generated recipe content is written in it RecipeRequestForwarded: type: object diff --git a/services/spring-api/src/test/kotlin/org/openapitools/api/AIApiTest.kt b/services/spring-api/src/test/kotlin/org/openapitools/api/AIApiTest.kt index c909099..097447b 100644 --- a/services/spring-api/src/test/kotlin/org/openapitools/api/AIApiTest.kt +++ b/services/spring-api/src/test/kotlin/org/openapitools/api/AIApiTest.kt @@ -9,6 +9,8 @@ import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mockito import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.openapitools.internal.client.HelpServiceApi import org.openapitools.internal.client.RecipeServiceApi @@ -29,6 +31,7 @@ import retrofit2.Call import retrofit2.Response import java.io.InterruptedIOException import java.math.BigDecimal +import kotlin.test.assertEquals @Import(AIApiTest.MockApiServices::class) class AIApiTest : ApiTestBase() { @@ -240,6 +243,27 @@ class AIApiTest : ApiTestBase() { .andExpect(jsonPath("$[0].title").value("AI Pasta")) } + @Test + fun `ai recipes - forwards the request language so recipes match the UI`() { + val token = register() + stubRecipeClient(listOf(sampleInternalRecipeInput)) + val bodyCaptor = argumentCaptor() + + mockMvc + .perform( + post("/api/v1/ai/recipes") + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"prompt":"Give me a pasta recipe","language":"DE"}"""), + ).andExpect(status().isOk) + + verify(mockApiServices.recipeServiceApi).aiRecipesPost(any(), any(), bodyCaptor.capture()) + assertEquals( + org.openapitools.internal.model.UserPreferences.Language.DE, + bodyCaptor.firstValue.profile.preferences.language, + ) + } + @Test fun `ai recipes - service returns 500 forwards 500`() { val token = register() diff --git a/web-client/src/api.ts b/web-client/src/api.ts index 859f3be..a6f1a1c 100644 --- a/web-client/src/api.ts +++ b/web-client/src/api.ts @@ -708,6 +708,11 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** + * @description Supported UI and AI-content language as an ISO 639-1 code + * @enum {string} + */ + Language: "EN" | "DE" | "HU"; ErrorResponse: { message: string; }; @@ -731,11 +736,8 @@ export interface components { password?: string; }; UserPreferences: { - /** - * @description Preferred UI and AI-content language as an ISO 639-1 code - * @enum {string} - */ - language?: "EN" | "DE" | "HU"; + /** @description Preferred UI and AI-content language as an ISO 639-1 code */ + language?: components["schemas"]["Language"]; /** @description Dietary restriction or style (e.g. vegan, keto) */ diet?: string[]; /** @description List of ingredients the user is allergic to */ @@ -799,6 +801,8 @@ export interface components { }; RecipeRequest: { prompt: string; + /** @description Active UI language; generated recipe content is written in it */ + language?: components["schemas"]["Language"]; }; HelpRequest: { recipe: components["schemas"]["RecipeInput"]; diff --git a/web-client/src/i18n.ts b/web-client/src/i18n.ts index 77286aa..ecdde75 100644 --- a/web-client/src/i18n.ts +++ b/web-client/src/i18n.ts @@ -14,7 +14,7 @@ const FALLBACK_LANGUAGE: Language = 'EN' const isSupported = (lang: string): lang is Language => (SUPPORTED_LANGUAGES as readonly string[]).includes(lang) -function detectInitialLanguage(): Language { +export function detectInitialLanguage(): Language { for (const tag of navigator.languages ?? [navigator.language]) { const base = tag.toUpperCase().split('-')[0] if (isSupported(base)) return base @@ -31,6 +31,11 @@ i18n.use(initReactI18next).init({ returnNull: false, }) +export function currentLanguage(): Language { + const lang = i18n.resolvedLanguage + return lang && isSupported(lang) ? lang : FALLBACK_LANGUAGE +} + export function applyUserLanguage(lang: string | undefined | null) { if (lang && isSupported(lang) && i18n.resolvedLanguage !== lang) { void i18n.changeLanguage(lang) diff --git a/web-client/src/locales/de.ts b/web-client/src/locales/de.ts index be9adac..60e99a1 100644 --- a/web-client/src/locales/de.ts +++ b/web-client/src/locales/de.ts @@ -15,6 +15,7 @@ export const DE = { logout: 'Abmelden', preferences: 'Einstellungen', language: 'Sprache', + detectLanguage: 'Automatisch', english: 'English', german: 'Deutsch', hungarian: 'Magyar', diff --git a/web-client/src/locales/en.ts b/web-client/src/locales/en.ts index 5d8a75c..c042c5a 100644 --- a/web-client/src/locales/en.ts +++ b/web-client/src/locales/en.ts @@ -15,6 +15,7 @@ export const EN = { logout: 'Log out', preferences: 'Preferences', language: 'Language', + detectLanguage: 'Auto', english: 'English', german: 'Deutsch', hungarian: 'Magyar', diff --git a/web-client/src/locales/hu.ts b/web-client/src/locales/hu.ts index d7f0e74..b905be3 100644 --- a/web-client/src/locales/hu.ts +++ b/web-client/src/locales/hu.ts @@ -15,6 +15,7 @@ export const HU = { logout: 'Kijelentkezés', preferences: 'Beállítások', language: 'Nyelv', + detectLanguage: 'Auto', english: 'English', german: 'Deutsch', hungarian: 'Magyar', diff --git a/web-client/src/pages/GeneratePage.tsx b/web-client/src/pages/GeneratePage.tsx index c376a52..d3ccd49 100644 --- a/web-client/src/pages/GeneratePage.tsx +++ b/web-client/src/pages/GeneratePage.tsx @@ -12,6 +12,7 @@ import {localizeTagLabel} from '../locales/recipeTagLabels' import {usePressPulse} from '../usePressPulse' import {errorMessage} from '../apiError' import {SessionExpiredError, useApi} from '../useApi' +import {currentLanguage} from '../i18n' // id is stored on the recipe once it is saved type Recipe = components['schemas']['RecipeInput'] & { id?: number } @@ -76,7 +77,7 @@ export function GenerateFlow() { try { const tagLabels = selectedTags.map((id) => tagsById.get(id)?.label).filter(Boolean) const fullPrompt = tagLabels.length > 0 ? `${prompt}\n\nPreferences: ${tagLabels.join(', ')}` : prompt - const body: RecipeRequest = {prompt: fullPrompt} + const body: RecipeRequest = {prompt: fullPrompt, language: currentLanguage()} const response = await apiFetch('/ai/recipes', { method: 'POST', headers: {'content-type': 'application/json'}, diff --git a/web-client/src/pages/ProfilePage.tsx b/web-client/src/pages/ProfilePage.tsx index 473f0fc..15f634d 100644 --- a/web-client/src/pages/ProfilePage.tsx +++ b/web-client/src/pages/ProfilePage.tsx @@ -15,20 +15,24 @@ import { usePressPulse } from '../usePressPulse' import { usePrefsAutosave } from '../usePrefsAutosave' import { errorMessage } from '../apiError' import { SessionExpiredError, useApi } from '../useApi' +import { detectInitialLanguage } from '../i18n' type UserProfile = components['schemas']['UserProfile'] type UserProfileUpdate = components['schemas']['UserProfileUpdate'] type Language = NonNullable +type LanguageSetting = Language | 'detect' + const LANGUAGE_OPTIONS = [ + { code: 'detect', labelKey: 'profile.detectLanguage' }, { code: 'EN', labelKey: 'profile.english' }, { code: 'DE', labelKey: 'profile.german' }, { code: 'HU', labelKey: 'profile.hungarian' }, -] as const satisfies readonly { code: Language; labelKey: string }[] +] as const satisfies readonly { code: LanguageSetting; labelKey: string }[] // un-trimmed preferences as held by the inputs -type PrefsDraft = { language: Language; aboutMe: string[]; diet: string[]; allergies: string[] } +type PrefsDraft = { language: LanguageSetting; aboutMe: string[]; diet: string[]; allergies: string[] } const trimList = (xs: string[]) => xs.map((x) => x.trim()).filter((x) => x !== '') @@ -53,7 +57,7 @@ export function ProfilePage() { const newUsername = usernameDraft ?? username ?? '' const [newPassword, setNewPassword] = useState('') const [repeatNewPassword, setRepeatNewPassword] = useState('') - const [language, setLanguage] = useState('EN') + const [language, setLanguage] = useState('detect') const [aboutMe, setAboutMe] = useState([]) const [diet, setDiet] = useState(['']) const [allergies, setAllergies] = useState(['', '']) @@ -78,7 +82,7 @@ export function ProfilePage() { const data = (await res.json()) as UserProfile if (cancelled) return const prefs = data.preferences ?? {} - setLanguage(prefs.language ?? 'EN') + setLanguage(prefs.language ?? 'detect') setAboutMe(prefs.aboutMe ?? []) setDiet(prefs.diet?.length ? prefs.diet : ['']) setAllergies(prefs.allergies?.length ? prefs.allergies : ['', '']) @@ -117,7 +121,7 @@ export function ProfilePage() { updateProfile( { preferences: { - language: draft.language, + language: draft.language === 'detect' ? undefined : draft.language, diet: trimList(draft.diet), allergies: trimList(draft.allergies), aboutMe: trimList(draft.aboutMe), @@ -155,6 +159,12 @@ export function ProfilePage() { notifyEdit(field, draft) } + function changeLanguage(code: LanguageSetting) { + setLanguage(code) + void i18n.changeLanguage(code === 'detect' ? detectInitialLanguage() : code) + editPrefs('language', livePrefs({ language: code })) + } + async function deleteRow(list: 'diet' | 'allergies', index: number) { const setPending = list === 'diet' ? setPendingDietDeletion : setPendingAllergyDeletion const setValues = list === 'diet' ? setDiet : setAllergies @@ -278,7 +288,19 @@ export function ProfilePage() { {t('profile.language')} -
+ {/* Native dropdown on mobile, where the segmented control would overflow */} + +
{ - setLanguage(code) - void i18n.changeLanguage(code) - editPrefs('language', livePrefs({ language: code })) - }} + onClick={() => changeLanguage(code)} > {t(labelKey)} diff --git a/web-client/tests/i18n.test.ts b/web-client/tests/i18n.test.ts new file mode 100644 index 0000000..a443418 --- /dev/null +++ b/web-client/tests/i18n.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { detectInitialLanguage } from '../src/i18n' + +// detectInitialLanguage reads navigator.languages at call time, so stubbing the +// global navigator lets us simulate different browser language settings. +function withBrowserLanguages(languages: string[]) { + vi.stubGlobal('navigator', { ...navigator, languages, language: languages[0] }) +} + +describe('detectInitialLanguage', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('detects a non-English browser language', () => { + withBrowserLanguages(['de-DE', 'en-US']) + expect(detectInitialLanguage()).toBe('DE') + }) + + it('falls back to English when no browser language is supported', () => { + withBrowserLanguages(['fr-FR', 'es-ES']) + expect(detectInitialLanguage()).toBe('EN') + }) +}) diff --git a/web-client/tests/pages/GeneratePage.test.tsx b/web-client/tests/pages/GeneratePage.test.tsx index ba4ce1f..b5bc277 100644 --- a/web-client/tests/pages/GeneratePage.test.tsx +++ b/web-client/tests/pages/GeneratePage.test.tsx @@ -63,6 +63,22 @@ describe('GeneratePage', () => { expect(await screen.findByText('No recipes returned.')).toBeInTheDocument() }) + it('sends the active UI language with the recipe request', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse([recipe])) + const user = userEvent.setup() + render() + + await user.type(screen.getByPlaceholderText(/Type what you think/i), 'pasta') + await user.click(screen.getByRole('button', { name: 'Generate' })) + + const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST') + expect(post).toBeDefined() + expect(JSON.parse(post![1]!.body as string)).toMatchObject({ + prompt: expect.stringContaining('pasta'), + language: 'EN', + }) + }) + it('shows a server error on the results page', async () => { fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'GenAI down' }, { status: 503 })) const user = userEvent.setup() diff --git a/web-client/tests/pages/ProfilePage.test.tsx b/web-client/tests/pages/ProfilePage.test.tsx index b7314e1..cebef07 100644 --- a/web-client/tests/pages/ProfilePage.test.tsx +++ b/web-client/tests/pages/ProfilePage.test.tsx @@ -3,6 +3,7 @@ import {act, cleanup, screen, waitFor, within} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {afterEach, beforeEach, vi} from 'vitest' import {ProfilePage} from '../../src/pages/ProfilePage' +import i18n from '../../src/i18n' import {jsonResponse, renderWithProviders} from '../utils' const fetchMock = vi.fn() @@ -84,6 +85,50 @@ describe('ProfilePage', () => { expect(await screen.findByText('login page')).toBeInTheDocument() }) + describe('language slider', () => { + // picking a language flips the app-wide i18n language, so restore it between tests + afterEach(async () => { + await i18n.changeLanguage('EN') + }) + + const putCalls = () => fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT') + const lastPutBody = () => JSON.parse(putCalls().at(-1)![1]!.body as string) + + function profileFetch(preferences: Record) { + fetchMock.mockImplementation((_input, init) => { + if ((init?.method ?? 'GET') === 'GET') { + return Promise.resolve(jsonResponse({username: 'alice', preferences})) + } + return Promise.resolve(jsonResponse({})) + }) + } + + it('defaults to Auto when the profile has no stored language', async () => { + profileFetch({}) + render() + expect(await screen.findByRole('button', {name: 'Auto', pressed: true})).toBeInTheDocument() + }) + + it('clears the stored language and re-detects the browser language when Auto is picked', async () => { + // start with the app shown in German and German selected + await i18n.changeLanguage('DE') + profileFetch({language: 'DE'}) + const user = userEvent.setup() + render() + await screen.findByRole('button', {name: 'Deutsch', pressed: true}) + expect(screen.getByText('Sprache')).toBeInTheDocument() + + await user.click(screen.getByRole('button', {name: 'Automatisch'})) + + // jsdom reports en-US, so detection falls back to English + expect(await screen.findByText('Language')).toBeInTheDocument() + expect(screen.getByRole('button', {name: 'Auto', pressed: true})).toBeInTheDocument() + // no language is sent, clearing the stored preference + await waitFor(() => expect(putCalls().length).toBeGreaterThan(0), {timeout: 2000}) + expect(lastPutBody().preferences.language).toBeUndefined() + }) + }) + describe('taste-preferences autosave', () => { const status = () => document.querySelector('[data-status]:not([data-status="idle"])') ?.getAttribute('data-status') ?? 'idle'