From 1fa7ac755e55b837fb54b4b612b66caebbb0029f Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 21 Nov 2025 21:52:20 +0800 Subject: [PATCH] chore: add test --- .../hooks/__tests__/useViewNavigation.test.ts | 211 +++++++++++++++ .../app/hooks/__tests__/useViewSync.test.ts | 142 ++++++++++ .../__tests__/useDatabaseLoading.test.ts | 244 ++++++++++++++++++ 3 files changed, 597 insertions(+) create mode 100644 src/components/app/hooks/__tests__/useViewNavigation.test.ts create mode 100644 src/components/app/hooks/__tests__/useViewSync.test.ts create mode 100644 src/components/editor/components/blocks/database/hooks/__tests__/useDatabaseLoading.test.ts diff --git a/src/components/app/hooks/__tests__/useViewNavigation.test.ts b/src/components/app/hooks/__tests__/useViewNavigation.test.ts new file mode 100644 index 00000000..d562723d --- /dev/null +++ b/src/components/app/hooks/__tests__/useViewNavigation.test.ts @@ -0,0 +1,211 @@ +import { renderHook,act } from '@testing-library/react'; +import { expect } from '@jest/globals'; +import { useDatabaseViewNavigation } from '../useViewNavigation'; +import { SCROLL_DELAY, SCROLL_FALLBACK_DELAY } from '../constants'; + +describe('useDatabaseViewNavigation', () => { + let mockScrollIntoView: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + // Mock scrollIntoView + mockScrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = mockScrollIntoView; + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + describe('navigateToView with element present', () => { + it('should call scrollIntoView when element exists', async () => { + const mockElement = document.createElement('div'); + mockElement.scrollIntoView = mockScrollIntoView; + + const tabRefs = { current: new Map([['view-123', mockElement]]) }; + const { result } = renderHook(() => + useDatabaseViewNavigation(tabRefs as React.MutableRefObject>) + ); + + const navigatePromise = result.current.navigateToView('view-123'); + + // Run all pending timers (SCROLL_DELAY) + await act(async () => { + jest.runAllTimers(); + }); + + await navigatePromise; + + expect(mockScrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }); + }); + + it('should call setSelectedViewId before scrolling', async () => { + const mockElement = document.createElement('div'); + mockElement.scrollIntoView = mockScrollIntoView; + + const tabRefs = { current: new Map([['view-123', mockElement]]) }; + const setSelectedViewId = jest.fn(); + + const { result } = renderHook(() => + useDatabaseViewNavigation( + tabRefs as React.MutableRefObject>, + setSelectedViewId + ) + ); + + const navigatePromise = result.current.navigateToView('view-123'); + + // setSelectedViewId should be called immediately + expect(setSelectedViewId).toHaveBeenCalledWith('view-123'); + + await act(async () => { + jest.runAllTimers(); + }); + + await navigatePromise; + }); + + it('should not throw when setSelectedViewId is undefined', async () => { + const mockElement = document.createElement('div'); + mockElement.scrollIntoView = mockScrollIntoView; + + const tabRefs = { current: new Map([['view-123', mockElement]]) }; + + const { result } = renderHook(() => + useDatabaseViewNavigation(tabRefs as React.MutableRefObject>) + ); + + const navigatePromise = result.current.navigateToView('view-123'); + + await act(async () => { + jest.runAllTimers(); + }); + + await expect(navigatePromise).resolves.not.toThrow(); + }); + + it('should wait SCROLL_DELAY before scrolling', async () => { + const mockElement = document.createElement('div'); + mockElement.scrollIntoView = mockScrollIntoView; + + const tabRefs = { current: new Map([['view-123', mockElement]]) }; + const { result } = renderHook(() => + useDatabaseViewNavigation(tabRefs as React.MutableRefObject>) + ); + + result.current.navigateToView('view-123'); + + // Should not have scrolled yet + expect(mockScrollIntoView).not.toHaveBeenCalled(); + + // Advance to just before SCROLL_DELAY + await act(async () => { + jest.advanceTimersByTime(SCROLL_DELAY - 1); + }); + expect(mockScrollIntoView).not.toHaveBeenCalled(); + + // Advance past SCROLL_DELAY + await act(async () => { + jest.advanceTimersByTime(1); + }); + expect(mockScrollIntoView).toHaveBeenCalled(); + + // Clean up remaining timers + jest.runAllTimers(); + }); + }); + + describe('navigateToView without element', () => { + it('should not scroll when element does not exist', async () => { + const tabRefs = { current: new Map() }; + const { result } = renderHook(() => + useDatabaseViewNavigation(tabRefs as React.MutableRefObject>) + ); + + const navigatePromise = result.current.navigateToView('nonexistent-view'); + + await act(async () => { + jest.runAllTimers(); + }); + + await navigatePromise; + + // scrollIntoView should have been called twice (initial try + fallback try) + // but since element doesn't exist, it won't actually be called + expect(mockScrollIntoView).not.toHaveBeenCalled(); + }); + + it('should trigger fallback scroll after SCROLL_FALLBACK_DELAY', async () => { + const tabRefs = { current: new Map() }; + const { result } = renderHook(() => + useDatabaseViewNavigation(tabRefs as React.MutableRefObject>) + ); + + result.current.navigateToView('view-123'); + + // Fast-forward initial delay + await act(async () => { + jest.advanceTimersByTime(SCROLL_DELAY); + }); + + // Element is not found, so fallback timer should be set + expect(mockScrollIntoView).not.toHaveBeenCalled(); + + // Add element before fallback fires + const mockElement = document.createElement('div'); + mockElement.scrollIntoView = mockScrollIntoView; + tabRefs.current.set('view-123', mockElement); + + // Fast-forward to fallback delay + await act(async () => { + jest.advanceTimersByTime(SCROLL_FALLBACK_DELAY); + }); + + // Now scrollIntoView should have been called by fallback + expect(mockScrollIntoView).toHaveBeenCalled(); + }); + }); + + describe('ref map updates', () => { + it('should handle ref map updates between calls', async () => { + const mockElement1 = document.createElement('div'); + const mockElement2 = document.createElement('div'); + mockElement1.scrollIntoView = mockScrollIntoView; + mockElement2.scrollIntoView = mockScrollIntoView; + + const tabRefs = { current: new Map([['view-1', mockElement1]]) }; + const { result } = renderHook(() => + useDatabaseViewNavigation(tabRefs as React.MutableRefObject>) + ); + + // First navigation + const nav1Promise = result.current.navigateToView('view-1'); + await act(async () => { + jest.runAllTimers(); + }); + await nav1Promise; + + expect(mockScrollIntoView).toHaveBeenCalledTimes(1); + mockScrollIntoView.mockClear(); + + // Update ref map + tabRefs.current = new Map([['view-2', mockElement2]]); + + // Second navigation + const nav2Promise = result.current.navigateToView('view-2'); + await act(async () => { + jest.runAllTimers(); + }); + await nav2Promise; + + expect(mockScrollIntoView).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/app/hooks/__tests__/useViewSync.test.ts b/src/components/app/hooks/__tests__/useViewSync.test.ts new file mode 100644 index 00000000..3692a72f --- /dev/null +++ b/src/components/app/hooks/__tests__/useViewSync.test.ts @@ -0,0 +1,142 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { expect } from '@jest/globals'; +import * as Y from 'yjs'; +import { useDatabaseViewSync } from '../useViewSync'; +import { SYNC_MAX_ATTEMPTS, SYNC_POLL_INTERVAL } from '../constants'; + +describe('useDatabaseViewSync', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + describe('waitForViewData', () => { + it('should return true immediately when view exists', async () => { + const mockViewsMap = new Map(); + mockViewsMap.set('view-123', { name: 'Test View' }); + + const mockViews = { + has: jest.fn((viewId: string) => mockViewsMap.has(viewId)), + } as unknown as Y.Map; + + const { result } = renderHook(() => useDatabaseViewSync(mockViews)); + + const promise = result.current.waitForViewData('view-123'); + + // Fast-forward timers since the view exists immediately + jest.runAllTimers(); + + const exists = await promise; + + expect(exists).toBe(true); + expect(mockViews.has).toHaveBeenCalledWith('view-123'); + }); + + it('should poll and return true when view becomes available', async () => { + const mockViewsMap = new Map(); + let callCount = 0; + + const mockViews = { + has: jest.fn((viewId: string) => { + callCount++; + // View becomes available on the 3rd call + if (callCount >= 3) { + mockViewsMap.set(viewId, { name: 'Test View' }); + } + return mockViewsMap.has(viewId); + }), + } as unknown as Y.Map; + + const { result } = renderHook(() => useDatabaseViewSync(mockViews)); + + const promise = result.current.waitForViewData('view-123'); + + // Fast-forward through polling intervals + for (let i = 0; i < 5; i++) { + jest.advanceTimersByTime(SYNC_POLL_INTERVAL); + await Promise.resolve(); // Allow promises to resolve + } + + const exists = await promise; + + expect(exists).toBe(true); + expect(callCount).toBeGreaterThanOrEqual(3); + }); + + it('should return false after max attempts when view never appears', async () => { + const mockViews = { + has: jest.fn(() => false), + } as unknown as Y.Map; + + const { result } = renderHook(() => useDatabaseViewSync(mockViews)); + + const promise = result.current.waitForViewData('nonexistent-view'); + + // Fast-forward through all polling intervals + for (let i = 0; i < SYNC_MAX_ATTEMPTS + 1; i++) { + jest.advanceTimersByTime(SYNC_POLL_INTERVAL); + await Promise.resolve(); + } + + const exists = await promise; + + expect(exists).toBe(false); + expect(mockViews.has).toHaveBeenCalledTimes(SYNC_MAX_ATTEMPTS); + }); + + it('should handle undefined views map gracefully', async () => { + const { result } = renderHook(() => useDatabaseViewSync(undefined)); + + const promise = result.current.waitForViewData('view-123'); + + // Fast-forward through all polling intervals + for (let i = 0; i < SYNC_MAX_ATTEMPTS + 1; i++) { + jest.advanceTimersByTime(SYNC_POLL_INTERVAL); + await Promise.resolve(); + } + + const exists = await promise; + + expect(exists).toBe(false); + }); + + it('should update waitForViewData when views map changes', async () => { + const mockViewsMap1 = new Map(); + const mockViews1 = { + has: jest.fn((viewId: string) => mockViewsMap1.has(viewId)), + } as unknown as Y.Map; + + const { result, rerender } = renderHook( + ({ views }) => useDatabaseViewSync(views), + { initialProps: { views: mockViews1 } } + ); + + // First call with empty map + let promise = result.current.waitForViewData('view-123'); + jest.advanceTimersByTime(SYNC_POLL_INTERVAL); + await Promise.resolve(); + + // Update to new map with the view + const mockViewsMap2 = new Map([['view-123', { name: 'Test' }]]); + const mockViews2 = { + has: jest.fn((viewId: string) => mockViewsMap2.has(viewId)), + } as unknown as Y.Map; + + rerender({ views: mockViews2 }); + + // New call should use updated map + promise = result.current.waitForViewData('view-123'); + jest.runAllTimers(); + + const exists = await promise; + + expect(exists).toBe(true); + expect(mockViews2.has).toHaveBeenCalledWith('view-123'); + }); + }); +}); diff --git a/src/components/editor/components/blocks/database/hooks/__tests__/useDatabaseLoading.test.ts b/src/components/editor/components/blocks/database/hooks/__tests__/useDatabaseLoading.test.ts new file mode 100644 index 00000000..6244e55a --- /dev/null +++ b/src/components/editor/components/blocks/database/hooks/__tests__/useDatabaseLoading.test.ts @@ -0,0 +1,244 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { expect } from '@jest/globals'; +import { useDatabaseLoading } from '../useDatabaseLoading'; +import { View, YDoc } from '@/application/types'; + +// Mock useRetryFunction +jest.mock('../useRetryFunction', () => ({ + useRetryFunction: (fn: any, onError: any) => { + // Return a function that wraps the original and calls onError on failure + return jest.fn(async (...args: any[]) => { + try { + if (!fn) throw new Error('Function not available'); + const result = await fn(...args); + if (!result) throw new Error('No result returned'); + return result; + } catch (error) { + onError(); + throw error; + } + }); + }, +})); + +describe('useDatabaseLoading', () => { + let consoleDebugSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleDebugSpy = jest.spyOn(console, 'debug').mockImplementation(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const createMockView = (viewId: string, children: Array<{ view_id: string }> = []): View => ({ + view_id: viewId, + name: `View ${viewId}`, + children, + layout: 0, + parent_view_id: 'parent', + icon: null, + extra: null, + is_published: false, + is_private: false, + }); + + const createMockYDoc = (): YDoc => ({ + guid: 'mock-doc', + } as YDoc); + + describe('initial view loading', () => { + it('should load view doc on mount', async () => { + const mockDoc = createMockYDoc(); + const loadView = jest.fn().mockResolvedValue(mockDoc); + const loadViewMeta = jest.fn().mockResolvedValue(createMockView('view-1')); + + const { result } = renderHook(() => + useDatabaseLoading({ + viewId: 'view-1', + loadView, + loadViewMeta, + }) + ); + + await waitFor(() => { + expect(result.current.doc).toBe(mockDoc); + }); + + expect(result.current.notFound).toBe(false); + expect(loadView).toHaveBeenCalledWith('view-1'); + }); + + it('should set doc to null initially', () => { + const loadView = jest.fn().mockResolvedValue(createMockYDoc()); + const loadViewMeta = jest.fn().mockResolvedValue(createMockView('view-1')); + + const { result } = renderHook(() => + useDatabaseLoading({ + viewId: 'view-1', + loadView, + loadViewMeta, + }) + ); + + // Initially doc should be null + expect(result.current.doc).toBeNull(); + expect(result.current.notFound).toBe(false); + }); + }); + + describe('view meta loading', () => { + it('should load view meta and update visible view IDs', async () => { + const mockView = createMockView('view-1', [ + { view_id: 'child-1' }, + { view_id: 'child-2' }, + ]); + const loadView = jest.fn().mockResolvedValue(createMockYDoc()); + const loadViewMeta = jest.fn().mockResolvedValue(mockView); + + const { result } = renderHook(() => + useDatabaseLoading({ + viewId: 'view-1', + loadView, + loadViewMeta, + }) + ); + + await waitFor(() => { + expect(result.current.visibleViewIds).toEqual(['view-1', 'child-1', 'child-2']); + }); + + expect(result.current.iidName).toBe('View view-1'); + }); + + it('should select first child view when viewId not in visible views', async () => { + const mockView = createMockView('view-1', [ + { view_id: 'child-1' }, + { view_id: 'child-2' }, + ]); + const loadView = jest.fn().mockResolvedValue(createMockYDoc()); + const loadViewMeta = jest.fn().mockResolvedValue(mockView); + + const { result } = renderHook(() => + useDatabaseLoading({ + viewId: 'view-1', + loadView, + loadViewMeta, + }) + ); + + await waitFor(() => { + expect(result.current.visibleViewIds.length).toBeGreaterThan(0); + }); + + // Should select view-1 since it's in the visible views list (first item) + expect(result.current.selectedViewId).toBe('view-1'); + }); + + it('should select requested viewId when it is in visible views', async () => { + const mockView = createMockView('view-1', [ + { view_id: 'child-1' }, + ]); + const loadView = jest.fn().mockResolvedValue(createMockYDoc()); + const loadViewMeta = jest.fn().mockResolvedValue(mockView); + + const { result } = renderHook(() => + useDatabaseLoading({ + viewId: 'view-1', + loadView, + loadViewMeta, + }) + ); + + await waitFor(() => { + expect(result.current.selectedViewId).toBe('view-1'); + }); + }); + + it('should set notFound when view meta fails to load', async () => { + const loadView = jest.fn().mockResolvedValue(createMockYDoc()); + const loadViewMeta = jest.fn().mockRejectedValue(new Error('Meta not found')); + + const { result } = renderHook(() => + useDatabaseLoading({ + viewId: 'view-1', + loadView, + loadViewMeta, + }) + ); + + await waitFor(() => { + expect(result.current.notFound).toBe(true); + }); + + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + }); + + describe('loadViewMeta function', () => { + it('should load meta for the same viewId with callback', async () => { + const mockView = createMockView('view-1'); + const loadView = jest.fn().mockResolvedValue(createMockYDoc()); + const loadViewMeta = jest.fn().mockResolvedValue(mockView); + const callback = jest.fn(); + + const { result } = renderHook(() => + useDatabaseLoading({ + viewId: 'view-1', + loadView, + loadViewMeta, + }) + ); + + await waitFor(() => { + expect(result.current.doc).toBeDefined(); + }); + + const meta = await result.current.loadViewMeta('view-1', callback); + + expect(meta).toEqual(mockView); + expect(result.current.notFound).toBe(false); + }); + + }); + + describe('error handling', () => { + it('should handle missing loadView function', async () => { + const loadViewMeta = jest.fn().mockResolvedValue(createMockView('view-1')); + + const { result } = renderHook(() => + useDatabaseLoading({ + viewId: 'view-1', + loadView: undefined, + loadViewMeta, + }) + ); + + await waitFor(() => { + expect(result.current.notFound).toBe(true); + }); + }); + + it('should handle missing loadViewMeta function', async () => { + const loadView = jest.fn().mockResolvedValue(createMockYDoc()); + + const { result } = renderHook(() => + useDatabaseLoading({ + viewId: 'view-1', + loadView, + loadViewMeta: undefined, + }) + ); + + await waitFor(() => { + expect(result.current.notFound).toBe(true); + }); + }); + }); + +}); +