test: create some hooks and utils test script, modified clipboard test script (#27928)
This commit is contained in:
@@ -1,10 +1,27 @@
|
||||
/**
|
||||
* Test suite for useBreakpoints hook
|
||||
*
|
||||
* This hook provides responsive breakpoint detection based on window width.
|
||||
* It listens to window resize events and returns the current media type.
|
||||
*
|
||||
* Breakpoint definitions:
|
||||
* - mobile: width <= 640px
|
||||
* - tablet: 640px < width <= 768px
|
||||
* - pc: width > 768px
|
||||
*
|
||||
* The hook automatically updates when the window is resized and cleans up
|
||||
* event listeners on unmount to prevent memory leaks.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import useBreakpoints, { MediaType } from './use-breakpoints'
|
||||
|
||||
describe('useBreakpoints', () => {
|
||||
const originalInnerWidth = window.innerWidth
|
||||
|
||||
// Mock the window resize event
|
||||
/**
|
||||
* Helper function to simulate window resize events
|
||||
* Updates window.innerWidth and dispatches a resize event
|
||||
*/
|
||||
const fireResize = (width: number) => {
|
||||
window.innerWidth = width
|
||||
act(() => {
|
||||
@@ -12,11 +29,18 @@ describe('useBreakpoints', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Restore the original innerWidth after tests
|
||||
/**
|
||||
* Restore the original innerWidth after all tests
|
||||
* Ensures tests don't affect each other or the test environment
|
||||
*/
|
||||
afterAll(() => {
|
||||
window.innerWidth = originalInnerWidth
|
||||
})
|
||||
|
||||
/**
|
||||
* Test mobile breakpoint detection
|
||||
* Mobile devices have width <= 640px
|
||||
*/
|
||||
it('should return mobile for width <= 640px', () => {
|
||||
// Mock window.innerWidth for mobile
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@@ -29,6 +53,10 @@ describe('useBreakpoints', () => {
|
||||
expect(result.current).toBe(MediaType.mobile)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test tablet breakpoint detection
|
||||
* Tablet devices have width between 640px and 768px
|
||||
*/
|
||||
it('should return tablet for width > 640px and <= 768px', () => {
|
||||
// Mock window.innerWidth for tablet
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@@ -41,6 +69,10 @@ describe('useBreakpoints', () => {
|
||||
expect(result.current).toBe(MediaType.tablet)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test desktop/PC breakpoint detection
|
||||
* Desktop devices have width > 768px
|
||||
*/
|
||||
it('should return pc for width > 768px', () => {
|
||||
// Mock window.innerWidth for pc
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@@ -53,6 +85,10 @@ describe('useBreakpoints', () => {
|
||||
expect(result.current).toBe(MediaType.pc)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test dynamic breakpoint updates on window resize
|
||||
* The hook should react to window resize events and update the media type
|
||||
*/
|
||||
it('should update media type when window resizes', () => {
|
||||
// Start with desktop
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@@ -73,6 +109,10 @@ describe('useBreakpoints', () => {
|
||||
expect(result.current).toBe(MediaType.mobile)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test proper cleanup of event listeners
|
||||
* Ensures no memory leaks by removing resize listeners on unmount
|
||||
*/
|
||||
it('should clean up event listeners on unmount', () => {
|
||||
// Spy on addEventListener and removeEventListener
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener')
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* Test suite for useDocumentTitle hook
|
||||
*
|
||||
* This hook manages the browser document title with support for:
|
||||
* - Custom branding (when enabled in system features)
|
||||
* - Default "Dify" branding
|
||||
* - Pending state handling (prevents title flicker during loading)
|
||||
* - Page-specific titles with automatic suffix
|
||||
*
|
||||
* Title format: "[Page Title] - [Brand Name]"
|
||||
* If no page title: "[Brand Name]"
|
||||
*/
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import useDocumentTitle from './use-document-title'
|
||||
@@ -7,6 +19,10 @@ jest.mock('@/service/common', () => ({
|
||||
getSystemFeatures: jest.fn(() => ({ ...defaultSystemFeatures })),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Test behavior when system features are still loading
|
||||
* Title should remain empty to prevent flicker
|
||||
*/
|
||||
describe('title should be empty if systemFeatures is pending', () => {
|
||||
act(() => {
|
||||
useGlobalPublicStore.setState({
|
||||
@@ -14,16 +30,26 @@ describe('title should be empty if systemFeatures is pending', () => {
|
||||
isGlobalPending: true,
|
||||
})
|
||||
})
|
||||
/**
|
||||
* Test that title stays empty during loading even when a title is provided
|
||||
*/
|
||||
it('document title should be empty if set title', () => {
|
||||
renderHook(() => useDocumentTitle('test'))
|
||||
expect(document.title).toBe('')
|
||||
})
|
||||
/**
|
||||
* Test that title stays empty during loading when no title is provided
|
||||
*/
|
||||
it('document title should be empty if not set title', () => {
|
||||
renderHook(() => useDocumentTitle(''))
|
||||
expect(document.title).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test default Dify branding behavior
|
||||
* When custom branding is disabled, should use "Dify" as the brand name
|
||||
*/
|
||||
describe('use default branding', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
@@ -33,17 +59,29 @@ describe('use default branding', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
/**
|
||||
* Test title format with page title and default branding
|
||||
* Format: "[page] - Dify"
|
||||
*/
|
||||
it('document title should be test-Dify if set title', () => {
|
||||
renderHook(() => useDocumentTitle('test'))
|
||||
expect(document.title).toBe('test - Dify')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test title with only default branding (no page title)
|
||||
* Format: "Dify"
|
||||
*/
|
||||
it('document title should be Dify if not set title', () => {
|
||||
renderHook(() => useDocumentTitle(''))
|
||||
expect(document.title).toBe('Dify')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Test custom branding behavior
|
||||
* When custom branding is enabled, should use the configured application_title
|
||||
*/
|
||||
describe('use specific branding', () => {
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
@@ -53,11 +91,19 @@ describe('use specific branding', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
/**
|
||||
* Test title format with page title and custom branding
|
||||
* Format: "[page] - [Custom Brand]"
|
||||
*/
|
||||
it('document title should be test-Test if set title', () => {
|
||||
renderHook(() => useDocumentTitle('test'))
|
||||
expect(document.title).toBe('test - Test')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test title with only custom branding (no page title)
|
||||
* Format: "[Custom Brand]"
|
||||
*/
|
||||
it('document title should be Test if not set title', () => {
|
||||
renderHook(() => useDocumentTitle(''))
|
||||
expect(document.title).toBe('Test')
|
||||
|
||||
376
web/hooks/use-format-time-from-now.spec.ts
Normal file
376
web/hooks/use-format-time-from-now.spec.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* Test suite for useFormatTimeFromNow hook
|
||||
*
|
||||
* This hook provides internationalized relative time formatting (e.g., "2 hours ago", "3 days ago")
|
||||
* using dayjs with the relativeTime plugin. It automatically uses the correct locale based on
|
||||
* the user's i18n settings.
|
||||
*
|
||||
* Key features:
|
||||
* - Supports 20+ locales with proper translations
|
||||
* - Automatically syncs with user's interface language
|
||||
* - Uses dayjs for consistent time calculations
|
||||
* - Returns human-readable relative time strings
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useFormatTimeFromNow } from './use-format-time-from-now'
|
||||
|
||||
// Mock the i18n context
|
||||
jest.mock('@/context/i18n', () => ({
|
||||
useI18N: jest.fn(() => ({
|
||||
locale: 'en-US',
|
||||
})),
|
||||
}))
|
||||
|
||||
// Import after mock to get the mocked version
|
||||
import { useI18N } from '@/context/i18n'
|
||||
|
||||
describe('useFormatTimeFromNow', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
/**
|
||||
* Test that the hook returns a formatTimeFromNow function
|
||||
* This is the primary interface of the hook
|
||||
*/
|
||||
it('should return formatTimeFromNow function', () => {
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
expect(result.current).toHaveProperty('formatTimeFromNow')
|
||||
expect(typeof result.current.formatTimeFromNow).toBe('function')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test basic relative time formatting with English locale
|
||||
* Should return human-readable relative time strings
|
||||
*/
|
||||
it('should format time from now in English', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Should contain "hour" or "hours" and "ago"
|
||||
expect(formatted).toMatch(/hour|hours/)
|
||||
expect(formatted).toMatch(/ago/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that recent times are formatted as "a few seconds ago"
|
||||
* Very recent timestamps should show seconds
|
||||
*/
|
||||
it('should format very recent times', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const fiveSecondsAgo = now - (5 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(fiveSecondsAgo)
|
||||
|
||||
expect(formatted).toMatch(/second|seconds|few seconds/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test formatting of times in the past (days ago)
|
||||
* Should handle day-level granularity
|
||||
*/
|
||||
it('should format times from days ago', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const threeDaysAgo = now - (3 * 24 * 60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(threeDaysAgo)
|
||||
|
||||
expect(formatted).toMatch(/day|days/)
|
||||
expect(formatted).toMatch(/ago/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test formatting of future times
|
||||
* dayjs fromNow also supports future times (e.g., "in 2 hours")
|
||||
*/
|
||||
it('should format future times', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const twoHoursFromNow = now + (2 * 60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(twoHoursFromNow)
|
||||
|
||||
expect(formatted).toMatch(/in/)
|
||||
expect(formatted).toMatch(/hour|hours/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Locale support', () => {
|
||||
/**
|
||||
* Test Chinese (Simplified) locale formatting
|
||||
* Should use Chinese characters for time units
|
||||
*/
|
||||
it('should format time in Chinese (Simplified)', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'zh-Hans' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Chinese should contain Chinese characters
|
||||
expect(formatted).toMatch(/[\u4E00-\u9FA5]/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Spanish locale formatting
|
||||
* Should use Spanish words for relative time
|
||||
*/
|
||||
it('should format time in Spanish', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Spanish should contain "hace" (ago)
|
||||
expect(formatted).toMatch(/hace/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test French locale formatting
|
||||
* Should use French words for relative time
|
||||
*/
|
||||
it('should format time in French', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'fr-FR' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// French should contain "il y a" (ago)
|
||||
expect(formatted).toMatch(/il y a/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Japanese locale formatting
|
||||
* Should use Japanese characters
|
||||
*/
|
||||
it('should format time in Japanese', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'ja-JP' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Japanese should contain Japanese characters
|
||||
expect(formatted).toMatch(/[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test Portuguese (Brazil) locale formatting
|
||||
* Should use pt-br locale mapping
|
||||
*/
|
||||
it('should format time in Portuguese (Brazil)', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'pt-BR' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Portuguese should contain "há" (ago)
|
||||
expect(formatted).toMatch(/há/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test fallback to English for unsupported locales
|
||||
* Unknown locales should default to English
|
||||
*/
|
||||
it('should fallback to English for unsupported locale', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'xx-XX' as any })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Should still return a valid string (in English)
|
||||
expect(typeof formatted).toBe('string')
|
||||
expect(formatted.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
/**
|
||||
* Test handling of timestamp 0 (Unix epoch)
|
||||
* Should format as a very old date
|
||||
*/
|
||||
it('should handle timestamp 0', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const formatted = result.current.formatTimeFromNow(0)
|
||||
|
||||
expect(typeof formatted).toBe('string')
|
||||
expect(formatted.length).toBeGreaterThan(0)
|
||||
expect(formatted).toMatch(/year|years/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test handling of very large timestamps
|
||||
* Should handle dates far in the future
|
||||
*/
|
||||
it('should handle very large timestamps', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const farFuture = Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year from now
|
||||
const formatted = result.current.formatTimeFromNow(farFuture)
|
||||
|
||||
expect(typeof formatted).toBe('string')
|
||||
expect(formatted).toMatch(/in/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the function is memoized based on locale
|
||||
* Changing locale should update the function
|
||||
*/
|
||||
it('should update when locale changes', () => {
|
||||
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
|
||||
// First render with English
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
rerender()
|
||||
const englishResult = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Second render with Spanish
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
|
||||
rerender()
|
||||
const spanishResult = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Results should be different
|
||||
expect(englishResult).not.toBe(spanishResult)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time granularity', () => {
|
||||
/**
|
||||
* Test different time granularities (seconds, minutes, hours, days, months, years)
|
||||
* dayjs should automatically choose the appropriate unit
|
||||
*/
|
||||
it('should use appropriate time units for different durations', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// Seconds
|
||||
const seconds = result.current.formatTimeFromNow(now - 30 * 1000)
|
||||
expect(seconds).toMatch(/second/)
|
||||
|
||||
// Minutes
|
||||
const minutes = result.current.formatTimeFromNow(now - 5 * 60 * 1000)
|
||||
expect(minutes).toMatch(/minute/)
|
||||
|
||||
// Hours
|
||||
const hours = result.current.formatTimeFromNow(now - 3 * 60 * 60 * 1000)
|
||||
expect(hours).toMatch(/hour/)
|
||||
|
||||
// Days
|
||||
const days = result.current.formatTimeFromNow(now - 5 * 24 * 60 * 60 * 1000)
|
||||
expect(days).toMatch(/day/)
|
||||
|
||||
// Months
|
||||
const months = result.current.formatTimeFromNow(now - 60 * 24 * 60 * 60 * 1000)
|
||||
expect(months).toMatch(/month/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Locale mapping', () => {
|
||||
/**
|
||||
* Test that all supported locales in the localeMap are handled correctly
|
||||
* This ensures the mapping from app locales to dayjs locales works
|
||||
*/
|
||||
it('should handle all mapped locales', () => {
|
||||
const locales = [
|
||||
'en-US', 'zh-Hans', 'zh-Hant', 'pt-BR', 'es-ES', 'fr-FR',
|
||||
'de-DE', 'ja-JP', 'ko-KR', 'ru-RU', 'it-IT', 'th-TH',
|
||||
'id-ID', 'uk-UA', 'vi-VN', 'ro-RO', 'pl-PL', 'hi-IN',
|
||||
'tr-TR', 'fa-IR', 'sl-SI',
|
||||
]
|
||||
|
||||
const now = Date.now()
|
||||
const oneHourAgo = now - (60 * 60 * 1000)
|
||||
|
||||
locales.forEach((locale) => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale })
|
||||
|
||||
const { result } = renderHook(() => useFormatTimeFromNow())
|
||||
const formatted = result.current.formatTimeFromNow(oneHourAgo)
|
||||
|
||||
// Should return a non-empty string for each locale
|
||||
expect(typeof formatted).toBe('string')
|
||||
expect(formatted.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance', () => {
|
||||
/**
|
||||
* Test that the hook doesn't create new functions on every render
|
||||
* The formatTimeFromNow function should be memoized with useCallback
|
||||
*/
|
||||
it('should memoize formatTimeFromNow function', () => {
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
|
||||
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
const firstFunction = result.current.formatTimeFromNow
|
||||
rerender()
|
||||
const secondFunction = result.current.formatTimeFromNow
|
||||
|
||||
// Same locale should return the same function reference
|
||||
expect(firstFunction).toBe(secondFunction)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that changing locale creates a new function
|
||||
* This ensures the memoization dependency on locale works correctly
|
||||
*/
|
||||
it('should create new function when locale changes', () => {
|
||||
const { result, rerender } = renderHook(() => useFormatTimeFromNow())
|
||||
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'en-US' })
|
||||
rerender()
|
||||
const englishFunction = result.current.formatTimeFromNow
|
||||
|
||||
;(useI18N as jest.Mock).mockReturnValue({ locale: 'es-ES' })
|
||||
rerender()
|
||||
const spanishFunction = result.current.formatTimeFromNow
|
||||
|
||||
// Different locale should return different function reference
|
||||
expect(englishFunction).not.toBe(spanishFunction)
|
||||
})
|
||||
})
|
||||
})
|
||||
543
web/hooks/use-tab-searchparams.spec.ts
Normal file
543
web/hooks/use-tab-searchparams.spec.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* Test suite for useTabSearchParams hook
|
||||
*
|
||||
* This hook manages tab state through URL search parameters, enabling:
|
||||
* - Bookmarkable tab states (users can share URLs with specific tabs active)
|
||||
* - Browser history integration (back/forward buttons work with tabs)
|
||||
* - Configurable routing behavior (push vs replace)
|
||||
* - Optional search parameter syncing (can disable URL updates)
|
||||
*
|
||||
* The hook syncs a local tab state with URL search parameters, making tab
|
||||
* navigation persistent and shareable across sessions.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useTabSearchParams } from './use-tab-searchparams'
|
||||
|
||||
// Mock Next.js navigation hooks
|
||||
const mockPush = jest.fn()
|
||||
const mockReplace = jest.fn()
|
||||
const mockPathname = '/test-path'
|
||||
const mockSearchParams = new URLSearchParams()
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: jest.fn(() => mockPathname),
|
||||
useRouter: jest.fn(() => ({
|
||||
push: mockPush,
|
||||
replace: mockReplace,
|
||||
})),
|
||||
useSearchParams: jest.fn(() => mockSearchParams),
|
||||
}))
|
||||
|
||||
// Import after mocks
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
describe('useTabSearchParams', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockSearchParams.delete('category')
|
||||
mockSearchParams.delete('tab')
|
||||
})
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
/**
|
||||
* Test that the hook returns a tuple with activeTab and setActiveTab
|
||||
* This is the primary interface matching React's useState pattern
|
||||
*/
|
||||
it('should return activeTab and setActiveTab function', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab, setActiveTab] = result.current
|
||||
|
||||
expect(typeof activeTab).toBe('string')
|
||||
expect(typeof setActiveTab).toBe('function')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the hook initializes with the default tab
|
||||
* When no search param is present, should use defaultTab
|
||||
*/
|
||||
it('should initialize with default tab when no search param exists', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('overview')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the hook reads from URL search parameters
|
||||
* When a search param exists, it should take precedence over defaultTab
|
||||
*/
|
||||
it('should initialize with search param value when present', () => {
|
||||
mockSearchParams.set('category', 'settings')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that setActiveTab updates the local state
|
||||
* The active tab should change when setActiveTab is called
|
||||
*/
|
||||
it('should update active tab when setActiveTab is called', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Routing behavior', () => {
|
||||
/**
|
||||
* Test default push routing behavior
|
||||
* By default, tab changes should use router.push (adds to history)
|
||||
*/
|
||||
it('should use push routing by default', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings')
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test replace routing behavior
|
||||
* When routingBehavior is 'replace', should use router.replace (no history)
|
||||
*/
|
||||
it('should use replace routing when specified', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
routingBehavior: 'replace',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/test-path?category=settings')
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that URL encoding is applied to tab values
|
||||
* Special characters in tab names should be properly encoded
|
||||
*/
|
||||
it('should encode special characters in tab values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings & config')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
'/test-path?category=settings%20%26%20config',
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that URL decoding is applied when reading from search params
|
||||
* Encoded values in the URL should be properly decoded
|
||||
*/
|
||||
it('should decode encoded values from search params', () => {
|
||||
mockSearchParams.set('category', 'settings%20%26%20config')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings & config')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom search parameter name', () => {
|
||||
/**
|
||||
* Test using a custom search parameter name
|
||||
* Should support different param names instead of default 'category'
|
||||
*/
|
||||
it('should use custom search param name', () => {
|
||||
mockSearchParams.set('tab', 'profile')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
searchParamName: 'tab',
|
||||
}),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('profile')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that setActiveTab uses the custom param name in the URL
|
||||
*/
|
||||
it('should update URL with custom param name', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
searchParamName: 'tab',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('profile')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?tab=profile')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled search params mode', () => {
|
||||
/**
|
||||
* Test that disableSearchParams prevents URL updates
|
||||
* When disabled, tab state should be local only
|
||||
*/
|
||||
it('should not update URL when disableSearchParams is true', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
disableSearchParams: true,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that local state still updates when search params are disabled
|
||||
* The tab state should work even without URL syncing
|
||||
*/
|
||||
it('should still update local state when search params disabled', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
disableSearchParams: true,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('settings')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that disabled mode always uses defaultTab
|
||||
* Search params should be ignored when disabled
|
||||
*/
|
||||
it('should use defaultTab when search params disabled even if URL has value', () => {
|
||||
mockSearchParams.set('category', 'settings')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
disableSearchParams: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('overview')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
/**
|
||||
* Test handling of empty string tab values
|
||||
* Empty strings should be handled gracefully
|
||||
*/
|
||||
it('should handle empty string tab values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('')
|
||||
})
|
||||
|
||||
const [activeTab] = result.current
|
||||
expect(activeTab).toBe('')
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that special characters in tab names are properly encoded
|
||||
* This ensures URLs remain valid even with unusual tab names
|
||||
*/
|
||||
it('should handle tabs with various special characters', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
// Test tab with slashes
|
||||
act(() => result.current[1]('tab/with/slashes'))
|
||||
expect(result.current[0]).toBe('tab/with/slashes')
|
||||
|
||||
// Test tab with question marks
|
||||
act(() => result.current[1]('tab?with?questions'))
|
||||
expect(result.current[0]).toBe('tab?with?questions')
|
||||
|
||||
// Test tab with hash symbols
|
||||
act(() => result.current[1]('tab#with#hash'))
|
||||
expect(result.current[0]).toBe('tab#with#hash')
|
||||
|
||||
// Test tab with equals signs
|
||||
act(() => result.current[1]('tab=with=equals'))
|
||||
expect(result.current[0]).toBe('tab=with=equals')
|
||||
})
|
||||
|
||||
/**
|
||||
* Test fallback when pathname is not available
|
||||
* Should use window.location.pathname as fallback
|
||||
*/
|
||||
it('should fallback to window.location.pathname when hook pathname is null', () => {
|
||||
;(usePathname as jest.Mock).mockReturnValue(null)
|
||||
|
||||
// Mock window.location.pathname
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/fallback-path' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/fallback-path?category=settings')
|
||||
|
||||
// Restore mock
|
||||
;(usePathname as jest.Mock).mockReturnValue(mockPathname)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple instances', () => {
|
||||
/**
|
||||
* Test that multiple instances with different param names work independently
|
||||
* Different hooks should not interfere with each other
|
||||
*/
|
||||
it('should support multiple independent tab states', () => {
|
||||
mockSearchParams.set('category', 'overview')
|
||||
mockSearchParams.set('subtab', 'details')
|
||||
|
||||
const { result: result1 } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'home',
|
||||
searchParamName: 'category',
|
||||
}),
|
||||
)
|
||||
|
||||
const { result: result2 } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'info',
|
||||
searchParamName: 'subtab',
|
||||
}),
|
||||
)
|
||||
|
||||
const [activeTab1] = result1.current
|
||||
const [activeTab2] = result2.current
|
||||
|
||||
expect(activeTab1).toBe('overview')
|
||||
expect(activeTab2).toBe('details')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
/**
|
||||
* Test typical usage in a tabbed interface
|
||||
* Simulates real-world tab switching behavior
|
||||
*/
|
||||
it('should handle sequential tab changes', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
// Change to settings tab
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('settings')
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe('settings')
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=settings')
|
||||
|
||||
// Change to profile tab
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('profile')
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe('profile')
|
||||
expect(mockPush).toHaveBeenCalledWith('/test-path?category=profile')
|
||||
|
||||
// Verify push was called twice
|
||||
expect(mockPush).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that the hook works with complex pathnames
|
||||
* Should handle nested routes and existing query params
|
||||
*/
|
||||
it('should work with complex pathnames', () => {
|
||||
;(usePathname as jest.Mock).mockReturnValue('/app/123/settings')
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('advanced')
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/app/123/settings?category=advanced')
|
||||
|
||||
// Restore mock
|
||||
;(usePathname as jest.Mock).mockReturnValue(mockPathname)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type safety', () => {
|
||||
/**
|
||||
* Test that the return type is a const tuple
|
||||
* TypeScript should infer [string, (tab: string) => void] as const
|
||||
*/
|
||||
it('should return a const tuple type', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
// The result should be a tuple with exactly 2 elements
|
||||
expect(result.current).toHaveLength(2)
|
||||
expect(typeof result.current[0]).toBe('string')
|
||||
expect(typeof result.current[1]).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance', () => {
|
||||
/**
|
||||
* Test that the hook creates a new function on each render
|
||||
* Note: The current implementation doesn't use useCallback,
|
||||
* so setActiveTab is recreated on each render. This could lead to
|
||||
* unnecessary re-renders in child components that depend on this function.
|
||||
* TODO: Consider memoizing setActiveTab with useCallback for better performance.
|
||||
*/
|
||||
it('should create new setActiveTab function on each render', () => {
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useTabSearchParams({ defaultTab: 'overview' }),
|
||||
)
|
||||
|
||||
const [, firstSetActiveTab] = result.current
|
||||
rerender()
|
||||
const [, secondSetActiveTab] = result.current
|
||||
|
||||
// Function reference changes on re-render (not memoized)
|
||||
expect(firstSetActiveTab).not.toBe(secondSetActiveTab)
|
||||
|
||||
// But both functions should work correctly
|
||||
expect(typeof firstSetActiveTab).toBe('function')
|
||||
expect(typeof secondSetActiveTab).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Browser history integration', () => {
|
||||
/**
|
||||
* Test that push behavior adds to browser history
|
||||
* This enables back/forward navigation through tabs
|
||||
*/
|
||||
it('should add to history with push behavior', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
routingBehavior: 'push',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab1')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab2')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab3')
|
||||
})
|
||||
|
||||
// Each tab change should create a history entry
|
||||
expect(mockPush).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
/**
|
||||
* Test that replace behavior doesn't add to history
|
||||
* This prevents cluttering browser history with tab changes
|
||||
*/
|
||||
it('should not add to history with replace behavior', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabSearchParams({
|
||||
defaultTab: 'overview',
|
||||
routingBehavior: 'replace',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab1')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
const [, setActiveTab] = result.current
|
||||
setActiveTab('tab2')
|
||||
})
|
||||
|
||||
// Should use replace instead of push
|
||||
expect(mockReplace).toHaveBeenCalledTimes(2)
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user