test: create some hooks and utils test script, modified clipboard test script (#27928)

This commit is contained in:
Gritty_dev
2025-11-12 08:47:06 -05:00
committed by GitHub
parent 19c92fd670
commit 5c06e285ec
9 changed files with 2289 additions and 3 deletions

View File

@@ -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')

View File

@@ -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')

View 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)
})
})
})

View 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()
})
})
})