/** * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' // Passthrough React hooks so the hook can run outside a React root. vi.mock('react', () => ({ useCallback: (fn: unknown) => fn, useEffect: (fn: () => void) => fn(), useRef: (init: unknown) => ({ current: init }), })) const mockMutate = vi.fn() const mockMutateAsync = vi.fn() vi.mock('@/hooks/queries/tables', () => ({ useUpdateTableRow: vi.fn(() => ({ mutate: mockMutate })), useCreateTableRow: vi.fn(() => ({ mutate: mockMutate })), useBatchCreateTableRows: vi.fn(() => ({ mutate: mockMutate, mutateAsync: mockMutateAsync })), useBatchUpdateTableRows: vi.fn(() => ({ mutate: mockMutate, mutateAsync: mockMutateAsync })), useDeleteTableRow: vi.fn(() => ({ mutate: mockMutate })), useDeleteTableRows: vi.fn(() => ({ mutate: mockMutate })), useAddTableColumn: vi.fn(() => ({ mutate: mockMutate })), useUpdateColumn: vi.fn(() => ({ mutate: mockMutate })), useDeleteColumn: vi.fn(() => ({ mutate: mockMutate })), useRenameTable: vi.fn(() => ({ mutate: mockMutate })), useUpdateTableMetadata: vi.fn(() => ({ mutate: mockMutate })), })) vi.mock('@/lib/table/constants', () => ({ TABLE_LIMITS: { MAX_BULK_OPERATION_SIZE: 3 }, // small limit so tests don't need 1000 items })) const mockPopUndo = vi.fn() const mockPopRedo = vi.fn() const mockPush = vi.fn() const mockPatchRedoRowId = vi.fn() const mockPatchUndoRowId = vi.fn() const mockClear = vi.fn() const storeState = { stacks: {}, push: mockPush, popUndo: mockPopUndo, popRedo: mockPopRedo, patchRedoRowId: mockPatchRedoRowId, patchUndoRowId: mockPatchUndoRowId, clear: mockClear, } vi.mock('@/stores/table/store', () => ({ useTableUndoStore: vi.fn((selector: (s: typeof storeState) => unknown) => selector(storeState)), runWithoutRecording: (fn: () => unknown) => Promise.resolve(fn()), })) import { useTableUndo } from '@/hooks/use-table-undo' import type { TableUndoAction } from '@/stores/table/types' const WORKSPACE_ID = 'ws-1' const TABLE_ID = 'tbl-1' function TestHook() { return useTableUndo({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID }) } function makeEntry(action: TableUndoAction) { return { id: 'e1', action, timestamp: Date.now() } } function makeCellsForClear(count: number) { return Array.from({ length: count }, (_, i) => ({ rowId: `row-${i}`, data: { col: `val-${i}` }, })) } /** Drain the microtask queue so all async chunks in executeAction finish. */ async function flush() { await new Promise((r) => setTimeout(r, 0)) } beforeEach(() => { vi.clearAllMocks() mockMutateAsync.mockResolvedValue({}) }) describe('useTableUndo – clear-cells chunking (via undo)', () => { it('sends a single mutateAsync call when cells fit in one chunk', async () => { const cells = makeCellsForClear(2) mockPopUndo.mockReturnValueOnce(makeEntry({ type: 'clear-cells', cells })) const { undo } = TestHook() ;(undo as () => void)() await flush() expect(mockMutateAsync).toHaveBeenCalledTimes(1) expect(mockMutateAsync.mock.calls[0][0].updates).toHaveLength(2) }) it('splits into multiple chunks when cells exceed the limit', async () => { const cells = makeCellsForClear(7) // limit=3 → [3,3,1] mockPopUndo.mockReturnValueOnce(makeEntry({ type: 'clear-cells', cells })) const { undo } = TestHook() ;(undo as () => void)() await flush() expect(mockMutateAsync).toHaveBeenCalledTimes(3) expect(mockMutateAsync.mock.calls[0][0].updates).toHaveLength(3) expect(mockMutateAsync.mock.calls[1][0].updates).toHaveLength(3) expect(mockMutateAsync.mock.calls[2][0].updates).toHaveLength(1) }) it('sends original data values for undo direction', async () => { const cells = makeCellsForClear(1) mockPopUndo.mockReturnValueOnce(makeEntry({ type: 'clear-cells', cells })) const { undo } = TestHook() ;(undo as () => void)() await flush() expect(mockMutateAsync.mock.calls[0][0].updates[0].data.col).toBe('val-0') }) it('sends null values for redo direction', async () => { const cells = makeCellsForClear(1) mockPopRedo.mockReturnValueOnce(makeEntry({ type: 'clear-cells', cells })) const { redo } = TestHook() ;(redo as () => void)() await flush() expect(mockMutateAsync.mock.calls[0][0].updates[0].data.col).toBeNull() }) it('does not call mutateAsync when cells is empty', async () => { mockPopUndo.mockReturnValueOnce(makeEntry({ type: 'clear-cells', cells: [] })) const { undo } = TestHook() ;(undo as () => void)() await flush() expect(mockMutateAsync).not.toHaveBeenCalled() }) it('stops processing after the first failing chunk', async () => { mockMutateAsync.mockRejectedValueOnce(new Error('Network error')) const cells = makeCellsForClear(5) // limit=3 → would be [3,2] but stops at chunk 1 mockPopUndo.mockReturnValueOnce(makeEntry({ type: 'clear-cells', cells })) const { undo } = TestHook() // executeAction catches the error internally via logger — undo itself doesn't re-throw. ;(undo as () => void)() await flush() expect(mockMutateAsync).toHaveBeenCalledTimes(1) }) }) describe('useTableUndo – update-cells chunking (via undo)', () => { function makeCellsForUpdate(count: number) { return Array.from({ length: count }, (_, i) => ({ rowId: `row-${i}`, oldData: { col: `old-${i}` }, newData: { col: `new-${i}` }, })) } it('sends a single call when cells fit within limit', async () => { const cells = makeCellsForUpdate(2) mockPopUndo.mockReturnValueOnce(makeEntry({ type: 'update-cells', cells })) const { undo } = TestHook() ;(undo as () => void)() await flush() expect(mockMutateAsync).toHaveBeenCalledTimes(1) expect(mockMutateAsync.mock.calls[0][0].updates[0].data.col).toBe('old-0') }) it('chunks across multiple calls and picks the correct direction data', async () => { const cells = makeCellsForUpdate(8) // limit=3 → [3,3,2] mockPopRedo.mockReturnValueOnce(makeEntry({ type: 'update-cells', cells })) const { redo } = TestHook() ;(redo as () => void)() await flush() expect(mockMutateAsync).toHaveBeenCalledTimes(3) const lastChunk = mockMutateAsync.mock.calls[2][0].updates expect(lastChunk).toHaveLength(2) // redo direction → newData expect(lastChunk[0].data.col).toBe('new-6') }) }) describe('useTableUndo – delete-column undo cell restore chunking', () => { const baseAction: TableUndoAction = { type: 'delete-column', columnName: 'col', columnType: 'string' as const, columnPosition: 0, columnUnique: false, columnRequired: false, cellData: [], previousOrder: null, previousWidth: null, previousPinnedColumns: null, } it('does not call mutateAsync when cellData is empty', async () => { mockPopUndo.mockReturnValueOnce(makeEntry(baseAction)) const { undo } = TestHook() ;(undo as () => void)() await flush() // addColumnMutation.mutate fires but the cell-restore block should not. expect(mockMutateAsync).not.toHaveBeenCalled() }) it('fires chunked mutateAsync calls via the onSuccess IIFE when cellData exceeds limit', async () => { const cellData = Array.from({ length: 5 }, (_, i) => ({ rowId: `row-${i}`, value: i })) const action: TableUndoAction = { ...baseAction, cellData } mockPopUndo.mockReturnValueOnce(makeEntry(action)) // addColumnMutation.mutate is the first mockMutate call. // Capture its onSuccess and invoke it to simulate column creation completing. let capturedOnSuccess: (() => void) | undefined mockMutate.mockImplementationOnce((_: unknown, opts: { onSuccess?: () => void }) => { capturedOnSuccess = opts?.onSuccess }) const { undo } = TestHook() ;(undo as () => void)() await flush() // At this point executeAction has returned, but the restore happens in the // addColumn onSuccess callback — fire it now. capturedOnSuccess?.() // Allow the void IIFE's microtasks to drain. await new Promise((r) => setTimeout(r, 0)) // limit=3 → [3, 2] expect(mockMutateAsync).toHaveBeenCalledTimes(2) expect(mockMutateAsync.mock.calls[0][0].updates).toHaveLength(3) expect(mockMutateAsync.mock.calls[1][0].updates).toHaveLength(2) }) })