diff --git a/web/src/shared/components/language-switcher.test.ts b/web/src/shared/components/language-switcher.test.ts
deleted file mode 100644
index 6408939c..00000000
--- a/web/src/shared/components/language-switcher.test.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import * as mod from './language-switcher'
-
-/**
- * LanguageSwitcher renders an in-tree locale menu (no Radix portal) driven by i18next.
- * There are no exported pure helpers; verify the export contract only.
- */
-describe('language-switcher module exports', () => {
- it('exports the LanguageSwitcher component', () => {
- expect(mod.LanguageSwitcher).toBeTypeOf('function')
- })
-})
diff --git a/web/src/shared/components/language-switcher.test.tsx b/web/src/shared/components/language-switcher.test.tsx
new file mode 100644
index 00000000..90862592
--- /dev/null
+++ b/web/src/shared/components/language-switcher.test.tsx
@@ -0,0 +1,64 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { LanguageSwitcher } from './language-switcher'
+
+const mockI18n = vi.hoisted(() => ({
+ language: undefined as string | undefined,
+ resolvedLanguage: undefined as string | undefined,
+ changeLanguage: vi.fn(),
+}))
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ i18n: mockI18n }),
+}))
+
+describe('LanguageSwitcher', () => {
+ beforeEach(() => {
+ mockI18n.language = undefined
+ mockI18n.resolvedLanguage = undefined
+ mockI18n.changeLanguage.mockImplementation((language: string) => {
+ mockI18n.language = language
+ mockI18n.resolvedLanguage = language
+ return Promise.resolve()
+ })
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+ })
+
+ it('exports the LanguageSwitcher component', () => {
+ expect(LanguageSwitcher).toBeTypeOf('function')
+ })
+
+ it('falls back to English when i18next has not resolved a language yet', () => {
+ render()
+
+ expect(screen.getByRole('button', { name: /English/i })).toBeTruthy()
+ })
+
+ it('uses the resolved language before the raw detected language', () => {
+ mockI18n.language = 'zh-CN'
+ mockI18n.resolvedLanguage = 'en'
+
+ render()
+
+ expect(screen.getByRole('button', { name: /English/i })).toBeTruthy()
+ })
+
+ it('updates the displayed language after selecting English', () => {
+ mockI18n.language = 'zh'
+ mockI18n.resolvedLanguage = 'zh'
+
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /中文/i }))
+ fireEvent.click(screen.getByRole('menuitem', { name: /English/i }))
+
+ expect(mockI18n.changeLanguage).toHaveBeenCalledWith('en')
+ expect(screen.getByRole('button', { name: /English/i })).toBeTruthy()
+ })
+})
diff --git a/web/src/shared/components/language-switcher.tsx b/web/src/shared/components/language-switcher.tsx
index 1b5756c9..1a298eec 100644
--- a/web/src/shared/components/language-switcher.tsx
+++ b/web/src/shared/components/language-switcher.tsx
@@ -27,9 +27,10 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
{ code: 'ru', name: 'Русский' },
]
+ const fallbackLanguage = languages.find((lang) => lang.code === 'en') ?? languages[0]
// Primary language code only (strip region, e.g. ru-RU → ru).
- const currentLangCode = i18n.language?.split('-')[0] || 'zh'
- const currentLanguage = languages.find((lang) => lang.code === currentLangCode) || languages[0]
+ const currentLangCode = (i18n.resolvedLanguage || i18n.language || fallbackLanguage.code).split('-')[0]
+ const currentLanguage = languages.find((lang) => lang.code === currentLangCode) || fallbackLanguage
useEffect(() => {
if (!open) {