mirror of https://github.com/langgenius/dify.git
test: unify i18next mocks into centralized helpers (#30376)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
parent
cad7101534
commit
2bb1e24fb4
|
|
@ -28,17 +28,14 @@ import userEvent from '@testing-library/user-event'
|
|||
|
||||
// i18n (automatically mocked)
|
||||
// WHY: Global mock in web/vitest.setup.ts is auto-loaded by Vitest setup
|
||||
// No explicit mock needed - it returns translation keys as-is
|
||||
// The global mock provides: useTranslation, Trans, useMixedTranslation, useGetLanguage
|
||||
// No explicit mock needed for most tests
|
||||
//
|
||||
// Override only if custom translations are required:
|
||||
// vi.mock('react-i18next', () => ({
|
||||
// useTranslation: () => ({
|
||||
// t: (key: string) => {
|
||||
// const customTranslations: Record<string, string> = {
|
||||
// 'my.custom.key': 'Custom Translation',
|
||||
// }
|
||||
// return customTranslations[key] || key
|
||||
// },
|
||||
// }),
|
||||
// import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
// vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
// 'my.custom.key': 'Custom Translation',
|
||||
// 'button.save': 'Save',
|
||||
// }))
|
||||
|
||||
// Router (if component uses useRouter, usePathname, useSearchParams)
|
||||
|
|
|
|||
|
|
@ -52,23 +52,29 @@ Modules are not mocked automatically. Use `vi.mock` in test files, or add global
|
|||
### 1. i18n (Auto-loaded via Global Mock)
|
||||
|
||||
A global mock is defined in `web/vitest.setup.ts` and is auto-loaded by Vitest setup.
|
||||
**No explicit mock needed** for most tests - it returns translation keys as-is.
|
||||
|
||||
For tests requiring custom translations, override the mock:
|
||||
The global mock provides:
|
||||
|
||||
- `useTranslation` - returns translation keys with namespace prefix
|
||||
- `Trans` component - renders i18nKey and components
|
||||
- `useMixedTranslation` (from `@/app/components/plugins/marketplace/hooks`)
|
||||
- `useGetLanguage` (from `@/context/i18n`) - returns `'en-US'`
|
||||
|
||||
**Default behavior**: Most tests should use the global mock (no local override needed).
|
||||
|
||||
**For custom translations**: Use the helper function from `@/test/i18n-mock`:
|
||||
|
||||
```typescript
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'my.custom.key': 'Custom translation',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
|
||||
vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
'my.custom.key': 'Custom translation',
|
||||
'button.save': 'Save',
|
||||
}))
|
||||
```
|
||||
|
||||
**Avoid**: Manually defining `useTranslation` mocks that just return the key - the global mock already does this.
|
||||
|
||||
### 2. Next.js Router
|
||||
|
||||
```typescript
|
||||
|
|
|
|||
|
|
@ -5,15 +5,6 @@ import * as React from 'react'
|
|||
import { AgentStrategy } from '@/types/app'
|
||||
import AgentSettingButton from './agent-setting-button'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
let latestAgentSettingProps: any
|
||||
vi.mock('./agent/agent-setting', () => ({
|
||||
default: (props: any) => {
|
||||
|
|
|
|||
|
|
@ -15,15 +15,6 @@ vi.mock('use-context-selector', async (importOriginal) => {
|
|||
}
|
||||
})
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockUseFeatures = vi.fn()
|
||||
const mockUseFeaturesStore = vi.fn()
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
|
|
|
|||
|
|
@ -1,26 +1,14 @@
|
|||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import InlineDeleteConfirm from './index'
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValueOrOptions?: string | { ns?: string }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'operation.deleteConfirmTitle': 'Delete?',
|
||||
'operation.yes': 'Yes',
|
||||
'operation.no': 'No',
|
||||
'operation.confirmAction': 'Please confirm your action.',
|
||||
}
|
||||
if (translations[key])
|
||||
return translations[key]
|
||||
// Handle case where second arg is default value string
|
||||
if (typeof defaultValueOrOptions === 'string')
|
||||
return defaultValueOrOptions
|
||||
const prefix = defaultValueOrOptions?.ns ? `${defaultValueOrOptions.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
// Mock react-i18next with custom translations for test assertions
|
||||
vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
'operation.deleteConfirmTitle': 'Delete?',
|
||||
'operation.yes': 'Yes',
|
||||
'operation.no': 'No',
|
||||
'operation.confirmAction': 'Please confirm your action.',
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import InputWithCopy from './index'
|
||||
|
||||
// Create a mock function that we can track using vi.hoisted
|
||||
|
|
@ -10,22 +11,12 @@ vi.mock('copy-to-clipboard', () => ({
|
|||
default: mockCopyToClipboard,
|
||||
}))
|
||||
|
||||
// Mock the i18n hook
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'operation.copy': 'Copy',
|
||||
'operation.copied': 'Copied',
|
||||
'overview.appInfo.embedded.copy': 'Copy',
|
||||
'overview.appInfo.embedded.copied': 'Copied',
|
||||
}
|
||||
if (translations[key])
|
||||
return translations[key]
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
// Mock the i18n hook with custom translations for test assertions
|
||||
vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
'operation.copy': 'Copy',
|
||||
'operation.copied': 'Copied',
|
||||
'overview.appInfo.embedded.copy': 'Copy',
|
||||
'overview.appInfo.embedded.copied': 'Copied',
|
||||
}))
|
||||
|
||||
// Mock es-toolkit/compat debounce
|
||||
|
|
|
|||
|
|
@ -1,21 +1,12 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import Input, { inputVariants } from './index'
|
||||
|
||||
// Mock the i18n hook
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'operation.search': 'Search',
|
||||
'placeholder.input': 'Please input',
|
||||
}
|
||||
if (translations[key])
|
||||
return translations[key]
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
// Mock the i18n hook with custom translations for test assertions
|
||||
vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
'operation.search': 'Search',
|
||||
'placeholder.input': 'Please input',
|
||||
}))
|
||||
|
||||
describe('Input component', () => {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import * as React from 'react'
|
|||
import { CategoryEnum } from '.'
|
||||
import Footer from './footer'
|
||||
|
||||
let mockTranslations: Record<string, string> = {}
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (
|
||||
<a href={href} className={className} target={target} data-testid="pricing-link">
|
||||
|
|
@ -13,25 +11,9 @@ vi.mock('next/link', () => ({
|
|||
),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
if (mockTranslations[key])
|
||||
return mockTranslations[key]
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Footer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTranslations = {}
|
||||
})
|
||||
|
||||
// Rendering behavior
|
||||
|
|
|
|||
|
|
@ -18,16 +18,6 @@ const IndexingTypeValues = {
|
|||
// Mock External Dependencies
|
||||
// ==========================================
|
||||
|
||||
// Mock react-i18next (handled by global mock in web/vitest.setup.ts but we override for custom messages)
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock next/link
|
||||
vi.mock('next/link', () => {
|
||||
return function MockLink({ children, href }: { children: React.ReactNode, href: string }) {
|
||||
|
|
|
|||
|
|
@ -9,16 +9,6 @@ import Processing from './index'
|
|||
// Mock External Dependencies
|
||||
// ==========================================
|
||||
|
||||
// Mock react-i18next (handled by global mock in web/vitest.setup.ts but we override for custom messages)
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useDocLink - returns a function that generates doc URLs
|
||||
// Strips leading slash from path to match actual implementation behavior
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
|
|
|
|||
|
|
@ -21,33 +21,6 @@ import Card from './index'
|
|||
// Mock External Dependencies Only
|
||||
// ================================
|
||||
|
||||
// Mock react-i18next (translation hook)
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useMixedTranslation hook
|
||||
vi.mock('../marketplace/hooks', () => ({
|
||||
useMixedTranslation: (_locale?: string) => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
const translations: Record<string, string> = {
|
||||
'plugin.marketplace.partnerTip': 'Partner plugin',
|
||||
'plugin.marketplace.verifiedTip': 'Verified plugin',
|
||||
'plugin.installModal.installWarning': 'Install warning message',
|
||||
}
|
||||
return translations[fullKey] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useGetLanguage context
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
// Mock useTheme hook
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
|
|
|
|||
|
|
@ -64,26 +64,20 @@ vi.mock('@/context/app-context', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string } & Record<string, unknown>) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
// Handle interpolation params (excluding ns)
|
||||
const { ns: _ns, ...params } = options || {}
|
||||
if (Object.keys(params).length > 0) {
|
||||
return `${fullKey}:${JSON.stringify(params)}`
|
||||
}
|
||||
return fullKey
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, React.ReactNode> }) => (
|
||||
<span data-testid="trans">
|
||||
{i18nKey}
|
||||
{components?.trustSource}
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
vi.mock('react-i18next', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-i18next')>()
|
||||
const { createReactI18nextMock } = await import('@/test/i18n-mock')
|
||||
return {
|
||||
...actual,
|
||||
...createReactI18nextMock(),
|
||||
Trans: ({ i18nKey, components }: { i18nKey: string, components?: Record<string, React.ReactNode> }) => (
|
||||
<span data-testid="trans">
|
||||
{i18nKey}
|
||||
{components?.trustSource}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../../card', () => ({
|
||||
default: ({ payload, titleLeft }: {
|
||||
|
|
|
|||
|
|
@ -48,21 +48,6 @@ vi.mock('@/service/plugins', () => ({
|
|||
uploadFile: (...args: unknown[]) => mockUploadFile(...args),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string } & Record<string, unknown>) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
// Handle interpolation params (excluding ns)
|
||||
const { ns: _ns, ...params } = options || {}
|
||||
if (Object.keys(params).length > 0) {
|
||||
return `${fullKey}:${JSON.stringify(params)}`
|
||||
}
|
||||
return fullKey
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../card', () => ({
|
||||
default: ({ payload, isLoading, loadingFileName }: {
|
||||
payload: { name: string }
|
||||
|
|
|
|||
|
|
@ -27,17 +27,17 @@ import {
|
|||
// Mock External Dependencies Only
|
||||
// ================================
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock i18next-config
|
||||
vi.mock('@/i18n-config/i18next-config', () => ({
|
||||
default: {
|
||||
getFixedT: (_locale: string) => (key: string) => key,
|
||||
getFixedT: (_locale: string) => (key: string, options?: Record<string, unknown>) => {
|
||||
if (options && options.ns) {
|
||||
return `${options.ns}.${key}`
|
||||
}
|
||||
else {
|
||||
return key
|
||||
}
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
@ -617,8 +617,8 @@ describe('hooks', () => {
|
|||
it('should return translation key when no translation found', () => {
|
||||
const { result } = renderHook(() => useMixedTranslation())
|
||||
|
||||
// The mock returns key as-is
|
||||
expect(result.current.t('category.all', { ns: 'plugin' })).toBe('category.all')
|
||||
// The global mock returns key with namespace prefix
|
||||
expect(result.current.t('category.all', { ns: 'plugin' })).toBe('plugin.category.all')
|
||||
})
|
||||
|
||||
it('should use locale from outer when provided', () => {
|
||||
|
|
@ -638,8 +638,8 @@ describe('hooks', () => {
|
|||
|
||||
it('should use getFixedT when localeFromOuter is provided', () => {
|
||||
const { result } = renderHook(() => useMixedTranslation('fr-FR'))
|
||||
// Should still return a function
|
||||
expect(result.current.t('search', { ns: 'plugin' })).toBe('search')
|
||||
// The global mock returns key with namespace prefix
|
||||
expect(result.current.t('search', { ns: 'plugin' })).toBe('plugin.search')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -2756,15 +2756,15 @@ describe('PluginTypeSwitch Component', () => {
|
|||
</MarketplaceContextProvider>,
|
||||
)
|
||||
|
||||
// Note: The mock returns the key without namespace prefix
|
||||
expect(screen.getByText('category.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('category.models')).toBeInTheDocument()
|
||||
expect(screen.getByText('category.tools')).toBeInTheDocument()
|
||||
expect(screen.getByText('category.datasources')).toBeInTheDocument()
|
||||
expect(screen.getByText('category.triggers')).toBeInTheDocument()
|
||||
expect(screen.getByText('category.agents')).toBeInTheDocument()
|
||||
expect(screen.getByText('category.extensions')).toBeInTheDocument()
|
||||
expect(screen.getByText('category.bundles')).toBeInTheDocument()
|
||||
// Note: The global mock returns the key with namespace prefix (plugin.)
|
||||
expect(screen.getByText('plugin.category.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.category.models')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.category.tools')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.category.datasources')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.category.triggers')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.category.agents')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.category.extensions')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.category.bundles')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply className prop', () => {
|
||||
|
|
@ -2794,7 +2794,7 @@ describe('PluginTypeSwitch Component', () => {
|
|||
</MarketplaceContextProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('category.tools'))
|
||||
fireEvent.click(screen.getByText('plugin.category.tools'))
|
||||
expect(screen.getByTestId('active-type-display')).toHaveTextContent('tool')
|
||||
})
|
||||
|
||||
|
|
@ -2816,7 +2816,7 @@ describe('PluginTypeSwitch Component', () => {
|
|||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('set-model'))
|
||||
const modelOption = screen.getByText('category.models').closest('div')
|
||||
const modelOption = screen.getByText('plugin.category.models').closest('div')
|
||||
expect(modelOption).toHaveClass('shadow-xs')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -78,17 +78,6 @@ function createMockLogData(logs: TriggerLogEntity[] = []): { logs: TriggerLogEnt
|
|||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
const mockTranslate = vi.fn((key: string, options?: { ns?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
return fullKey
|
||||
})
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mockTranslate,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock plugin store
|
||||
const mockPluginDetail = createMockPluginDetail()
|
||||
const mockUsePluginStore = vi.fn(() => mockPluginDetail)
|
||||
|
|
|
|||
|
|
@ -68,17 +68,6 @@ function createMockSubscriptionBuilder(overrides: Partial<TriggerSubscriptionBui
|
|||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
const mockTranslate = vi.fn((key: string, options?: { ns?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
return fullKey
|
||||
})
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mockTranslate,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock plugin store
|
||||
const mockPluginDetail = createMockPluginDetail()
|
||||
const mockUsePluginStore = vi.fn(() => mockPluginDetail)
|
||||
|
|
|
|||
|
|
@ -12,16 +12,6 @@ import { OAuthEditModal } from './oauth-edit-modal'
|
|||
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
// Build full key with namespace prefix if provided
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
return fullKey
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: (params: unknown) => mockToastNotify(params) },
|
||||
|
|
|
|||
|
|
@ -9,28 +9,6 @@ import PluginMutationModal from './index'
|
|||
// Mock External Dependencies Only
|
||||
// ================================
|
||||
|
||||
// Mock react-i18next (translation hook)
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useMixedTranslation hook
|
||||
vi.mock('../marketplace/hooks', () => ({
|
||||
useMixedTranslation: (_locale?: string) => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
const fullKey = options?.ns ? `${options.ns}.${key}` : key
|
||||
return fullKey
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useGetLanguage context
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useGetLanguage: () => 'en-US',
|
||||
}))
|
||||
|
||||
// Mock useTheme hook
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: 'light' }),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
type TranslationMap = Record<string, string | string[]>
|
||||
|
||||
/**
|
||||
* Create a t function with optional custom translations
|
||||
* Checks translations[key] first, then translations[ns.key], then returns ns.key as fallback
|
||||
*/
|
||||
export function createTFunction(translations: TranslationMap, defaultNs?: string) {
|
||||
return (key: string, options?: Record<string, unknown>) => {
|
||||
// Check custom translations first (without namespace)
|
||||
if (translations[key] !== undefined)
|
||||
return translations[key]
|
||||
|
||||
const ns = (options?.ns as string | undefined) ?? defaultNs
|
||||
const fullKey = ns ? `${ns}.${key}` : key
|
||||
|
||||
// Check custom translations with namespace
|
||||
if (translations[fullKey] !== undefined)
|
||||
return translations[fullKey]
|
||||
|
||||
// Serialize params (excluding ns) for test assertions
|
||||
const params = { ...options }
|
||||
delete params.ns
|
||||
const suffix = Object.keys(params).length > 0 ? `:${JSON.stringify(params)}` : ''
|
||||
return `${fullKey}${suffix}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create useTranslation mock with optional custom translations
|
||||
*
|
||||
* @example
|
||||
* vi.mock('react-i18next', () => createUseTranslationMock({
|
||||
* 'operation.confirm': 'Confirm',
|
||||
* }))
|
||||
*/
|
||||
export function createUseTranslationMock(translations: TranslationMap = {}) {
|
||||
return {
|
||||
useTranslation: (defaultNs?: string) => ({
|
||||
t: createTFunction(translations, defaultNs),
|
||||
i18n: {
|
||||
language: 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Trans component mock with optional custom translations
|
||||
*/
|
||||
export function createTransMock(translations: TranslationMap = {}) {
|
||||
return {
|
||||
Trans: ({ i18nKey, children }: {
|
||||
i18nKey: string
|
||||
children?: React.ReactNode
|
||||
}) => {
|
||||
const text = translations[i18nKey] ?? i18nKey
|
||||
return React.createElement('span', { 'data-i18n-key': i18nKey }, children ?? text)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create complete react-i18next mock (useTranslation + Trans)
|
||||
*
|
||||
* @example
|
||||
* vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
* 'modal.title': 'My Modal',
|
||||
* }))
|
||||
*/
|
||||
export function createReactI18nextMock(translations: TranslationMap = {}) {
|
||||
return {
|
||||
...createUseTranslationMock(translations),
|
||||
...createTransMock(translations),
|
||||
}
|
||||
}
|
||||
|
|
@ -329,21 +329,28 @@ describe('ComponentName', () => {
|
|||
|
||||
1. **i18n**: Uses global mock in `web/vitest.setup.ts` (auto-loaded by Vitest setup)
|
||||
|
||||
The global mock returns translation keys as-is. For custom translations, override:
|
||||
The global mock provides:
|
||||
|
||||
- `useTranslation` - returns translation keys with namespace prefix
|
||||
- `Trans` component - renders i18nKey and components
|
||||
- `useMixedTranslation` (from `@/app/components/plugins/marketplace/hooks`)
|
||||
- `useGetLanguage` (from `@/context/i18n`) - returns `'en-US'`
|
||||
|
||||
**Default behavior**: Most tests should use the global mock (no local override needed).
|
||||
|
||||
**For custom translations**: Use the helper function from `@/test/i18n-mock`:
|
||||
|
||||
```typescript
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'my.custom.key': 'Custom translation',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
|
||||
vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
'my.custom.key': 'Custom translation',
|
||||
'button.save': 'Save',
|
||||
}))
|
||||
```
|
||||
|
||||
**Avoid**: Manually defining `useTranslation` mocks that just return the key - the global mock already does this.
|
||||
|
||||
1. **Forms**: Test validation logic thoroughly
|
||||
|
||||
1. **Example - Correct mock with conditional rendering**:
|
||||
|
|
|
|||
|
|
@ -88,26 +88,10 @@ vi.mock('next/image')
|
|||
// mock react-i18next
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
const { createReactI18nextMock } = await import('./test/i18n-mock')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: (defaultNs?: string) => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.returnObjects)
|
||||
return [`${key}-feature-1`, `${key}-feature-2`]
|
||||
const ns = options?.ns ?? defaultNs
|
||||
if (options || ns) {
|
||||
const { ns: _ns, ...rest } = options ?? {}
|
||||
const prefix = ns ? `${ns}.` : ''
|
||||
const suffix = Object.keys(rest).length > 0 ? `:${JSON.stringify(rest)}` : ''
|
||||
return `${prefix}${key}${suffix}`
|
||||
}
|
||||
return key
|
||||
},
|
||||
i18n: {
|
||||
language: 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
...createReactI18nextMock(),
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue