Add tests for useFileNavigation hook functionality

This commit is contained in:
2025-05-28 18:39:41 +02:00
parent 05c3111f8b
commit ae35172c2a

View File

@@ -0,0 +1,421 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useFileNavigation } from './useFileNavigation';
import { DEFAULT_FILE } from '@/types/models';
// Mock dependencies
const mockWorkspaceData: {
currentWorkspace: { id: number; name: string } | null;
} = {
currentWorkspace: {
id: 1,
name: 'test-workspace',
},
};
const mockLastOpenedFile = {
loadLastOpenedFile: vi.fn(),
saveLastOpenedFile: vi.fn(),
};
vi.mock('../contexts/WorkspaceDataContext', () => ({
useWorkspaceData: () => mockWorkspaceData,
}));
vi.mock('./useLastOpenedFile', () => ({
useLastOpenedFile: () => mockLastOpenedFile,
}));
describe('useFileNavigation', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset workspace data to defaults
mockWorkspaceData.currentWorkspace = {
id: 1,
name: 'test-workspace',
};
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initial state', () => {
it('starts with default file selected', () => {
const { result } = renderHook(() => useFileNavigation());
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
expect(result.current.isNewFile).toBe(true);
expect(typeof result.current.handleFileSelect).toBe('function');
});
it('loads last opened file on mount when available', async () => {
mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue(
'documents/readme.md'
);
const { result } = renderHook(() => useFileNavigation());
await waitFor(() => {
expect(result.current.selectedFile).toBe('documents/readme.md');
expect(result.current.isNewFile).toBe(false);
});
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled();
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith(
'documents/readme.md'
);
});
it('stays with default file when no last opened file exists', async () => {
mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue(null);
const { result } = renderHook(() => useFileNavigation());
await waitFor(() => {
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
expect(result.current.isNewFile).toBe(true);
});
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled();
expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled();
});
});
describe('handleFileSelect', () => {
it('selects a regular file correctly', async () => {
const { result } = renderHook(() => useFileNavigation());
await act(async () => {
await result.current.handleFileSelect('notes/todo.md');
});
expect(result.current.selectedFile).toBe('notes/todo.md');
expect(result.current.isNewFile).toBe(false);
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith(
'notes/todo.md'
);
});
it('handles null file selection (defaults to default file)', async () => {
const { result } = renderHook(() => useFileNavigation());
await act(async () => {
await result.current.handleFileSelect(null);
});
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
expect(result.current.isNewFile).toBe(true);
expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled();
});
it('handles empty string file selection', async () => {
const { result } = renderHook(() => useFileNavigation());
await act(async () => {
await result.current.handleFileSelect('');
});
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
expect(result.current.isNewFile).toBe(true);
expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled();
});
it('handles different file path formats', async () => {
const { result } = renderHook(() => useFileNavigation());
const testCases = [
'simple.md',
'folder/file.md',
'deep/nested/path/document.md',
'file with spaces.md',
'special-chars_123.md',
'unicode-文档.md',
];
for (const filePath of testCases) {
await act(async () => {
await result.current.handleFileSelect(filePath);
});
expect(result.current.selectedFile).toBe(filePath);
expect(result.current.isNewFile).toBe(false);
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith(
filePath
);
}
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledTimes(
testCases.length
);
});
it('handles rapid file selections', async () => {
const { result } = renderHook(() => useFileNavigation());
const files = ['file1.md', 'file2.md', 'file3.md'];
await act(async () => {
await Promise.all(
files.map((file) => result.current.handleFileSelect(file))
);
});
// Should end up with the last file (depending on async timing)
expect(files).toContain(result.current.selectedFile);
expect(result.current.isNewFile).toBe(false);
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledTimes(3);
});
it('handles file selection errors gracefully', async () => {
mockLastOpenedFile.saveLastOpenedFile.mockRejectedValue(
new Error('Save failed')
);
const { result } = renderHook(() => useFileNavigation());
// Should not throw
await act(async () => {
await result.current.handleFileSelect('error-file.md');
});
expect(result.current.selectedFile).toBe('error-file.md');
expect(result.current.isNewFile).toBe(false);
});
});
describe('workspace changes', () => {
it('reinitializes when workspace changes', async () => {
mockLastOpenedFile.loadLastOpenedFile
.mockResolvedValueOnce('workspace1-file.md')
.mockResolvedValueOnce('workspace2-file.md');
const { result, rerender } = renderHook(() => useFileNavigation());
// Wait for initial load
await waitFor(() => {
expect(result.current.selectedFile).toBe('workspace1-file.md');
});
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalledTimes(1);
// Change workspace
mockWorkspaceData.currentWorkspace = {
id: 2,
name: 'different-workspace',
};
rerender();
// Should reinitialize with new workspace
await waitFor(() => {
expect(result.current.selectedFile).toBe('workspace2-file.md');
});
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalledTimes(2);
});
it('handles workspace becoming null', async () => {
const { result, rerender } = renderHook(() => useFileNavigation());
// Start with workspace
await waitFor(() => {
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled();
});
// Remove workspace
mockWorkspaceData.currentWorkspace = null;
rerender();
// Should still work but with default behavior
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
expect(result.current.isNewFile).toBe(true);
});
it('handles workspace reappearing', async () => {
mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue(
'restored-file.md'
);
const { result, rerender } = renderHook(() => useFileNavigation());
// Start with no workspace
mockWorkspaceData.currentWorkspace = null;
rerender();
// Add workspace back
mockWorkspaceData.currentWorkspace = {
id: 1,
name: 'restored-workspace',
};
rerender();
// Should reinitialize
await waitFor(() => {
expect(result.current.selectedFile).toBe('restored-file.md');
});
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled();
});
});
describe('initialization scenarios', () => {
it('handles loadLastOpenedFile returning empty string', async () => {
mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue('');
const { result } = renderHook(() => useFileNavigation());
await waitFor(() => {
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
expect(result.current.isNewFile).toBe(true);
});
});
it('handles loadLastOpenedFile errors', async () => {
mockLastOpenedFile.loadLastOpenedFile.mockRejectedValue(
new Error('Load failed')
);
const { result } = renderHook(() => useFileNavigation());
// Should fallback to default file
await waitFor(() => {
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
expect(result.current.isNewFile).toBe(true);
});
});
it('handles successful load followed by handleFileSelect', async () => {
mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue(
'initial-file.md'
);
const { result } = renderHook(() => useFileNavigation());
// Wait for initial load
await waitFor(() => {
expect(result.current.selectedFile).toBe('initial-file.md');
expect(result.current.isNewFile).toBe(false);
});
// Then select a different file
await act(async () => {
await result.current.handleFileSelect('different-file.md');
});
expect(result.current.selectedFile).toBe('different-file.md');
expect(result.current.isNewFile).toBe(false);
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith(
'different-file.md'
);
});
});
describe('state consistency', () => {
it('maintains correct isNewFile state for default file', async () => {
const { result } = renderHook(() => useFileNavigation());
// Initially should be new file
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
expect(result.current.isNewFile).toBe(true);
// Select a real file
await act(async () => {
await result.current.handleFileSelect('real-file.md');
});
expect(result.current.isNewFile).toBe(false);
// Go back to null (should default to default file)
await act(async () => {
await result.current.handleFileSelect(null);
});
expect(result.current.selectedFile).toBe(DEFAULT_FILE.path);
expect(result.current.isNewFile).toBe(true);
});
it('maintains correct isNewFile state for regular files', async () => {
const { result } = renderHook(() => useFileNavigation());
const testFiles = ['file1.md', 'file2.md', 'folder/file3.md'];
for (const file of testFiles) {
await act(async () => {
await result.current.handleFileSelect(file);
});
expect(result.current.selectedFile).toBe(file);
expect(result.current.isNewFile).toBe(false);
}
});
});
describe('hook interface stability', () => {
it('handleFileSelect function is stable across re-renders', () => {
const { result, rerender } = renderHook(() => useFileNavigation());
const initialHandler = result.current.handleFileSelect;
rerender();
expect(result.current.handleFileSelect).toBe(initialHandler);
});
it('returns consistent interface', () => {
const { result } = renderHook(() => useFileNavigation());
expect(typeof result.current.selectedFile).toBe('string');
expect(typeof result.current.isNewFile).toBe('boolean');
expect(typeof result.current.handleFileSelect).toBe('function');
});
});
describe('integration with useLastOpenedFile', () => {
it('calls loadLastOpenedFile on mount', async () => {
renderHook(() => useFileNavigation());
await waitFor(() => {
expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled();
});
});
it('calls saveLastOpenedFile when selecting files', async () => {
const { result } = renderHook(() => useFileNavigation());
await act(async () => {
await result.current.handleFileSelect('test-file.md');
});
expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith(
'test-file.md'
);
});
it('does not call saveLastOpenedFile for null selections', async () => {
const { result } = renderHook(() => useFileNavigation());
await act(async () => {
await result.current.handleFileSelect(null);
});
expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled();
});
it('handles saveLastOpenedFile errors without affecting state', async () => {
mockLastOpenedFile.saveLastOpenedFile.mockRejectedValue(
new Error('Save error')
);
const { result } = renderHook(() => useFileNavigation());
await act(async () => {
await result.current.handleFileSelect('test-file.md');
});
// State should still be updated despite save error
expect(result.current.selectedFile).toBe('test-file.md');
expect(result.current.isNewFile).toBe(false);
});
});
});