mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-11-28 10:18:47 +08:00
chore: add test
This commit is contained in:
211
src/components/app/hooks/__tests__/useViewNavigation.test.ts
Normal file
211
src/components/app/hooks/__tests__/useViewNavigation.test.ts
Normal file
@@ -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<Map<string, HTMLElement>>)
|
||||
);
|
||||
|
||||
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<Map<string, HTMLElement>>,
|
||||
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<Map<string, HTMLElement>>)
|
||||
);
|
||||
|
||||
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<Map<string, HTMLElement>>)
|
||||
);
|
||||
|
||||
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<Map<string, HTMLElement>>)
|
||||
);
|
||||
|
||||
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<Map<string, HTMLElement>>)
|
||||
);
|
||||
|
||||
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<Map<string, HTMLElement>>)
|
||||
);
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
142
src/components/app/hooks/__tests__/useViewSync.test.ts
Normal file
142
src/components/app/hooks/__tests__/useViewSync.test.ts
Normal file
@@ -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<any>;
|
||||
|
||||
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<any>;
|
||||
|
||||
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<any>;
|
||||
|
||||
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<any>;
|
||||
|
||||
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<any>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user