/** * Tests for HistoryContext */ import { describe, it, expect, vi } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { HistoryProvider, useHistory, useHistoryKeyboard } from '@/contexts/HistoryContext'; import React from 'react'; // Wrapper component for testing const createWrapper = (initialState?: any, onStateChange?: (state: any) => void) => { return ({ children }: { children: React.ReactNode }) => ( {children} ); }; describe('HistoryContext', () => { it('should initialize with no history', () => { const wrapper = createWrapper(); const { result } = renderHook(() => useHistory(), { wrapper }); expect(result.current.canUndo).toBe(false); expect(result.current.canRedo).toBe(false); expect(result.current.historySize).toBe(0); expect(result.current.currentEntry).toBeNull(); }); it('should initialize with initial state', () => { const initialState = { count: 0 }; const wrapper = createWrapper(initialState); const { result } = renderHook(() => useHistory(), { wrapper }); expect(result.current.currentEntry).not.toBeNull(); expect(result.current.currentEntry?.state).toEqual(initialState); expect(result.current.currentEntry?.action).toBe('Initial state'); expect(result.current.historySize).toBe(1); }); it('should push new state to history', () => { const wrapper = createWrapper({ count: 0 }); const { result } = renderHook(() => useHistory(), { wrapper }); act(() => { result.current.pushHistory({ count: 1 }, 'Increment'); }); expect(result.current.currentEntry?.state).toEqual({ count: 1 }); expect(result.current.currentEntry?.action).toBe('Increment'); expect(result.current.canUndo).toBe(true); expect(result.current.canRedo).toBe(false); expect(result.current.historySize).toBe(2); }); it('should undo to previous state', () => { const wrapper = createWrapper({ count: 0 }); const { result } = renderHook(() => useHistory(), { wrapper }); act(() => { result.current.pushHistory({ count: 1 }, 'Increment'); result.current.pushHistory({ count: 2 }, 'Increment'); }); expect(result.current.currentEntry?.state).toEqual({ count: 2 }); let previousState: any; act(() => { previousState = result.current.undo(); }); expect(previousState).toEqual({ count: 1 }); expect(result.current.currentEntry?.state).toEqual({ count: 1 }); expect(result.current.canUndo).toBe(true); expect(result.current.canRedo).toBe(true); }); it('should redo to next state', async () => { const wrapper = createWrapper({ count: 0 }); const { result } = renderHook(() => useHistory(), { wrapper }); act(() => { result.current.pushHistory({ count: 1 }, 'Increment'); }); act(() => { result.current.pushHistory({ count: 2 }, 'Increment'); }); act(() => { result.current.undo(); }); expect(result.current.currentEntry?.state).toEqual({ count: 1 }); let nextState: any; act(() => { nextState = result.current.redo(); }); expect(nextState).toEqual({ count: 2 }); expect(result.current.currentEntry?.state).toEqual({ count: 2 }); expect(result.current.canUndo).toBe(true); expect(result.current.canRedo).toBe(false); }); it('should return null when undoing with no past', () => { const wrapper = createWrapper({ count: 0 }); const { result } = renderHook(() => useHistory(), { wrapper }); let previousState: any; act(() => { previousState = result.current.undo(); }); expect(previousState).toBeNull(); expect(result.current.canUndo).toBe(false); }); it('should return null when redoing with no future', () => { const wrapper = createWrapper({ count: 0 }); const { result } = renderHook(() => useHistory(), { wrapper }); let nextState: any; act(() => { nextState = result.current.redo(); }); expect(nextState).toBeNull(); expect(result.current.canRedo).toBe(false); }); it('should clear future when new action is pushed', () => { const wrapper = createWrapper({ count: 0 }); const { result } = renderHook(() => useHistory(), { wrapper }); act(() => { result.current.pushHistory({ count: 1 }, 'Increment'); }); act(() => { result.current.pushHistory({ count: 2 }, 'Increment'); }); act(() => { result.current.undo(); }); expect(result.current.canRedo).toBe(true); act(() => { result.current.pushHistory({ count: 10 }, 'Set to 10'); }); expect(result.current.canRedo).toBe(false); expect(result.current.currentEntry?.state).toEqual({ count: 10 }); }); it('should clear all history', () => { const wrapper = createWrapper({ count: 0 }); const { result } = renderHook(() => useHistory(), { wrapper }); act(() => { result.current.pushHistory({ count: 1 }, 'Increment'); result.current.pushHistory({ count: 2 }, 'Increment'); }); expect(result.current.historySize).toBe(3); act(() => { result.current.clearHistory(); }); expect(result.current.historySize).toBe(0); expect(result.current.canUndo).toBe(false); expect(result.current.canRedo).toBe(false); expect(result.current.currentEntry).toBeNull(); }); it('should jump to specific entry', () => { const wrapper = createWrapper({ count: 0 }); const { result } = renderHook(() => useHistory(), { wrapper }); act(() => { result.current.pushHistory({ count: 1 }, 'Increment'); result.current.pushHistory({ count: 2 }, 'Increment'); result.current.pushHistory({ count: 3 }, 'Increment'); }); let targetState: any; act(() => { targetState = result.current.jumpToEntry(1); // Jump to count: 1 }); expect(targetState).toEqual({ count: 1 }); expect(result.current.currentEntry?.state).toEqual({ count: 1 }); expect(result.current.canUndo).toBe(true); expect(result.current.canRedo).toBe(true); }); it('should return null when jumping to invalid index', () => { const wrapper = createWrapper({ count: 0 }); const { result } = renderHook(() => useHistory(), { wrapper }); let targetState: any; act(() => { targetState = result.current.jumpToEntry(-1); }); expect(targetState).toBeNull(); act(() => { targetState = result.current.jumpToEntry(100); }); expect(targetState).toBeNull(); }); it('should respect max history size', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const { result } = renderHook(() => useHistory(), { wrapper }); act(() => { for (let i = 1; i <= 10; i++) { result.current.pushHistory({ count: i }, `Set to ${i}`); } }); // Max 3 past + 1 present = 4 total expect(result.current.historySize).toBeLessThanOrEqual(4); }); it('should get past and future entries', () => { const wrapper = createWrapper({ count: 0 }); const { result } = renderHook(() => useHistory(), { wrapper }); act(() => { result.current.pushHistory({ count: 1 }, 'Increment'); }); act(() => { result.current.pushHistory({ count: 2 }, 'Increment'); }); act(() => { result.current.undo(); }); const pastEntries = result.current.getPastEntries(); const futureEntries = result.current.getFutureEntries(); // After undo: past=[count:0], present=[count:1], future=[count:2] expect(pastEntries.length).toBe(1); // Only initial state expect(futureEntries.length).toBe(1); // The undone state expect(pastEntries[0].state).toEqual({ count: 0 }); expect(result.current.currentEntry?.state).toEqual({ count: 1 }); expect(futureEntries[0].state).toEqual({ count: 2 }); }); it('should call onStateChange when state changes', () => { const onStateChange = vi.fn(); const wrapper = createWrapper({ count: 0 }, onStateChange); const { result } = renderHook(() => useHistory(), { wrapper }); act(() => { result.current.pushHistory({ count: 1 }, 'Increment'); }); expect(onStateChange).toHaveBeenCalledWith({ count: 1 }); }); it('should throw error when useHistory is called outside provider', () => { // Suppress console.error for this test const consoleError = console.error; console.error = vi.fn(); expect(() => { renderHook(() => useHistory()); }).toThrow('useHistory must be used within a HistoryProvider'); console.error = consoleError; }); }); describe('useHistoryKeyboard', () => { it('should handle keyboard shortcuts for undo/redo', () => { const wrapper = createWrapper({ count: 0 }); const { result } = renderHook( () => { const history = useHistory(); useHistoryKeyboard(); return history; }, { wrapper } ); act(() => { result.current.pushHistory({ count: 1 }, 'Increment'); result.current.pushHistory({ count: 2 }, 'Increment'); }); expect(result.current.currentEntry?.state).toEqual({ count: 2 }); // Simulate Ctrl+Z (undo) act(() => { const event = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true, }); window.dispatchEvent(event); }); expect(result.current.currentEntry?.state).toEqual({ count: 1 }); // Simulate Ctrl+Shift+Z (redo) act(() => { const event = new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, shiftKey: true, bubbles: true, }); window.dispatchEvent(event); }); expect(result.current.currentEntry?.state).toEqual({ count: 2 }); }); });