chore: add test

This commit is contained in:
Nathan
2025-11-21 21:52:20 +08:00
parent ed728828b2
commit 1fa7ac755e
3 changed files with 597 additions and 0 deletions

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

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

View File

@@ -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);
});
});
});
});