diff --git a/__tests__/hooks/use-online-status.test.ts b/__tests__/hooks/use-online-status.test.ts new file mode 100644 index 0000000..5f0f351 --- /dev/null +++ b/__tests__/hooks/use-online-status.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useOnlineStatus } from '@/hooks/use-online-status' + +function setNavigatorOnline(online: boolean) { + Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online }) +} + +describe('useOnlineStatus', () => { + afterEach(() => { + setNavigatorOnline(true) + }) + + it('defaults to online', () => { + const { result } = renderHook(() => useOnlineStatus()) + expect(result.current).toBe(true) + }) + + it('syncs to navigator.onLine on mount', () => { + setNavigatorOnline(false) + const { result } = renderHook(() => useOnlineStatus()) + expect(result.current).toBe(false) + }) + + it('becomes false when the offline event fires', () => { + const { result } = renderHook(() => useOnlineStatus()) + + act(() => { + window.dispatchEvent(new Event('offline')) + }) + + expect(result.current).toBe(false) + }) + + it('becomes true again when the online event fires', () => { + const { result } = renderHook(() => useOnlineStatus()) + + act(() => { + window.dispatchEvent(new Event('offline')) + }) + expect(result.current).toBe(false) + + act(() => { + window.dispatchEvent(new Event('online')) + }) + expect(result.current).toBe(true) + }) + + it('removes its event listeners on unmount', () => { + const { result, unmount } = renderHook(() => useOnlineStatus()) + unmount() + + // No assertion possible on a removed listener directly, but dispatching + // after unmount must not throw (no dangling state updates on an + // unmounted component). + expect(() => window.dispatchEvent(new Event('offline'))).not.toThrow() + expect(result.current).toBe(true) + }) +}) diff --git a/__tests__/hooks/use-page-visibility.test.ts b/__tests__/hooks/use-page-visibility.test.ts new file mode 100644 index 0000000..07636e7 --- /dev/null +++ b/__tests__/hooks/use-page-visibility.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { usePageVisibility } from '@/hooks/use-page-visibility' + +// Issue #740 describes this hook as returning state that updates on +// visibilitychange. The real hook (hooks/use-page-visibility.ts) returns +// nothing — it's callback-based: it calls onVisible/onHidden depending on +// document.hidden when the event fires. These tests cover the real shape. + +function setDocumentHidden(hidden: boolean) { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden }) +} + +function fireVisibilityChange() { + document.dispatchEvent(new Event('visibilitychange')) +} + +describe('usePageVisibility', () => { + afterEach(() => { + setDocumentHidden(false) + }) + + it('calls onHidden when the document becomes hidden', () => { + const onVisible = vi.fn() + const onHidden = vi.fn() + renderHook(() => usePageVisibility({ onVisible, onHidden })) + + setDocumentHidden(true) + fireVisibilityChange() + + expect(onHidden).toHaveBeenCalledTimes(1) + expect(onVisible).not.toHaveBeenCalled() + }) + + it('calls onVisible when the document becomes visible again', () => { + const onVisible = vi.fn() + const onHidden = vi.fn() + renderHook(() => usePageVisibility({ onVisible, onHidden })) + + setDocumentHidden(true) + fireVisibilityChange() + setDocumentHidden(false) + fireVisibilityChange() + + expect(onHidden).toHaveBeenCalledTimes(1) + expect(onVisible).toHaveBeenCalledTimes(1) + }) + + it('does not throw when a callback is omitted', () => { + renderHook(() => usePageVisibility({ onHidden: vi.fn() })) + setDocumentHidden(false) + expect(() => fireVisibilityChange()).not.toThrow() + }) + + it('always calls the latest callback identity, even without re-registering the listener', () => { + const onHiddenFirst = vi.fn() + const onHiddenSecond = vi.fn() + const { rerender } = renderHook(({ onHidden }) => usePageVisibility({ onHidden }), { + initialProps: { onHidden: onHiddenFirst }, + }) + + rerender({ onHidden: onHiddenSecond }) + + setDocumentHidden(true) + fireVisibilityChange() + + expect(onHiddenFirst).not.toHaveBeenCalled() + expect(onHiddenSecond).toHaveBeenCalledTimes(1) + }) + + it('removes the listener on unmount', () => { + const onHidden = vi.fn() + const { unmount } = renderHook(() => usePageVisibility({ onHidden })) + + unmount() + setDocumentHidden(true) + fireVisibilityChange() + + expect(onHidden).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/hooks/use-streams-view-preference.test.ts b/__tests__/hooks/use-streams-view-preference.test.ts new file mode 100644 index 0000000..5b151d8 --- /dev/null +++ b/__tests__/hooks/use-streams-view-preference.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useStreamsViewPreference } from '@/hooks/use-streams-view-preference' + +const KEY = 'flowstar:streams-view' + +describe('useStreamsViewPreference', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('defaults to "list" when nothing is stored', () => { + const { result } = renderHook(() => useStreamsViewPreference()) + expect(result.current.view).toBe('list') + }) + + it('reads a previously stored preference', () => { + localStorage.setItem(KEY, 'compact') + const { result } = renderHook(() => useStreamsViewPreference()) + expect(result.current.view).toBe('compact') + }) + + it('ignores an invalid stored value and falls back to the default', () => { + localStorage.setItem(KEY, 'not-a-real-view') + const { result } = renderHook(() => useStreamsViewPreference()) + expect(result.current.view).toBe('list') + }) + + it('setView updates state and persists to localStorage', () => { + const { result } = renderHook(() => useStreamsViewPreference()) + + act(() => { + result.current.setView('timeline') + }) + + expect(result.current.view).toBe('timeline') + expect(localStorage.getItem(KEY)).toBe('timeline') + }) +}) diff --git a/__tests__/hooks/use-undo-cancel.test.ts b/__tests__/hooks/use-undo-cancel.test.ts new file mode 100644 index 0000000..048baa7 --- /dev/null +++ b/__tests__/hooks/use-undo-cancel.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' + +vi.mock('@/hooks/use-contract', () => ({ + useContract: vi.fn(), +})) + +vi.mock('sonner', () => { + const toast = vi.fn() as unknown as { (...args: unknown[]): void; dismiss: ReturnType; info: ReturnType } + toast.dismiss = vi.fn() + toast.info = vi.fn() + return { toast } +}) + +import { useContract } from '@/hooks/use-contract' +import { toast } from 'sonner' +import { useUndoableCancel, useIsStreamCancelling, CANCEL_UNDO_DELAY_MS } from '@/hooks/use-undo-cancel' + +const cancelMock = vi.fn() + +describe('useUndoableCancel / useIsStreamCancelling', () => { + beforeEach(() => { + vi.useFakeTimers() + cancelMock.mockReset().mockResolvedValue(undefined) + vi.mocked(useContract).mockReturnValue({ cancel: cancelMock } as unknown as ReturnType) + vi.mocked(toast).mockClear() + vi.mocked(toast.dismiss).mockClear() + vi.mocked(toast.info).mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('marks the stream as cancelling immediately when scheduled', () => { + const { result: cancelHook } = renderHook(() => useUndoableCancel()) + const { result: isCancelling } = renderHook(() => useIsStreamCancelling('stream-1')) + + expect(isCancelling.current).toBe(false) + + act(() => { + cancelHook.current.scheduleCancel('stream-1') + }) + + expect(isCancelling.current).toBe(true) + expect(cancelMock).not.toHaveBeenCalled() + }) + + it('submits the cancel transaction once the undo delay elapses (cancel-on-timeout)', async () => { + const { result: cancelHook } = renderHook(() => useUndoableCancel()) + const { result: isCancelling } = renderHook(() => useIsStreamCancelling('stream-1')) + + act(() => { + cancelHook.current.scheduleCancel('stream-1') + }) + expect(isCancelling.current).toBe(true) + + await act(async () => { + await vi.advanceTimersByTimeAsync(CANCEL_UNDO_DELAY_MS) + }) + + expect(cancelMock).toHaveBeenCalledWith('stream-1') + expect(isCancelling.current).toBe(false) + expect(toast.dismiss).toHaveBeenCalledWith('cancel-undo-stream-1') + }) + + it('aborting before the delay elapses cancels the countdown and never submits the transaction (undo-before-timeout)', async () => { + const { result: cancelHook } = renderHook(() => useUndoableCancel()) + const { result: isCancelling } = renderHook(() => useIsStreamCancelling('stream-1')) + + act(() => { + cancelHook.current.scheduleCancel('stream-1') + }) + + act(() => { + vi.advanceTimersByTime(CANCEL_UNDO_DELAY_MS / 2) + }) + expect(isCancelling.current).toBe(true) + + act(() => { + const aborted = cancelHook.current.abortCancel('stream-1') + expect(aborted).toBe(true) + }) + + expect(isCancelling.current).toBe(false) + + // Advancing past the original delay must not retroactively submit the + // cancel — the timers backing it were cleared by abortCancel. + await act(async () => { + await vi.advanceTimersByTimeAsync(CANCEL_UNDO_DELAY_MS) + }) + expect(cancelMock).not.toHaveBeenCalled() + }) + + it('abortCancel returns false when there is nothing pending for that stream', () => { + const { result: cancelHook } = renderHook(() => useUndoableCancel()) + + act(() => { + const aborted = cancelHook.current.abortCancel('never-scheduled') + expect(aborted).toBe(false) + }) + }) + + it('useIsStreamCancelling reacts to a cancellation scheduled by a different hook instance', () => { + const { result: cancelHookA } = renderHook(() => useUndoableCancel()) + const { result: isCancellingB } = renderHook(() => useIsStreamCancelling('shared-stream')) + + expect(isCancellingB.current).toBe(false) + + act(() => { + cancelHookA.current.scheduleCancel('shared-stream') + }) + + // A separate hook instance subscribed to the same stream id observes the + // change via the shared pub/sub, without any prop-drilling between them. + expect(isCancellingB.current).toBe(true) + }) + + it('unmounting the scheduling component aborts any in-flight countdown', async () => { + const { result: cancelHook, unmount } = renderHook(() => useUndoableCancel()) + const { result: isCancelling } = renderHook(() => useIsStreamCancelling('stream-1')) + + act(() => { + cancelHook.current.scheduleCancel('stream-1') + }) + expect(isCancelling.current).toBe(true) + + unmount() + expect(isCancelling.current).toBe(false) + + await act(async () => { + await vi.advanceTimersByTimeAsync(CANCEL_UNDO_DELAY_MS) + }) + expect(cancelMock).not.toHaveBeenCalled() + }) +})