/** * 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) }) }) })