From fecfc6424742173699e199305e0c166adf1ca9e9 Mon Sep 17 00:00:00 2001 From: MamaLove360 Date: Sun, 27 Sep 2026 15:44:49 +0100 Subject: [PATCH] Add unit tests for use-streams-view-preference, use-undo-cancel, use-page-visibility, and use-online-status hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #741: Added tests for useStreamsViewPreference covering the default ("list") value, reading a previously-stored preference back, ignoring an invalid stored value, and that setView both updates state and persists to localStorage — following the same pattern as the existing useShowUsd tests. Issue #742: Added tests for useUndoableCancel / useIsStreamCancelling covering: scheduling a cancel marks the stream as cancelling immediately; letting the countdown run out submits the cancel transaction and clears the cancelling state (cancel-on-timeout); calling abortCancel before the countdown finishes clears it without ever submitting the transaction (undo-before-timeout); abortCancel returns false when nothing is pending; useIsStreamCancelling reacts to a cancellation scheduled by a different hook instance via the shared pub/sub; and unmounting the scheduling component aborts any in-flight countdown as a safe default. Mocked useContract and sonner's toast, matching this repo's existing mocking conventions for hooks that call out to the contract layer. Issue #740: The issue described usePageVisibility as returning state that updates on visibilitychange events. The real hook is callback-based — it takes onVisible/onHidden callbacks and calls whichever applies when the event fires; it returns nothing. Added tests for the real shape instead: onHidden fires when the document becomes hidden, onVisible fires when it becomes visible again, a call with only one callback provided doesn't throw, the latest callback identity is used even without re-registering the listener (via the hook's internal refs), and the listener is removed on unmount. Issue #739: Added tests for useOnlineStatus covering the default (online) value, syncing to navigator.onLine on mount, becoming false on the offline event, becoming true again on the online event, and not throwing after unmount. Verification: ran the full existing test suite before adding these files. The pre-existing failures (dashboard, hero, settings, live-stream-preview, sections, wallet-lobstr, empty-state, dropdown-menu — 26 failing tests across 8 files) match the same known-unrelated baseline already seen on another fork of this same project, so they predate and are unrelated to this change. All 20 new tests across the 4 files pass. Closes #739 Closes #740 Closes #741 Closes #742 --- __tests__/hooks/use-online-status.test.ts | 59 ++++++++ __tests__/hooks/use-page-visibility.test.ts | 81 +++++++++++ .../hooks/use-streams-view-preference.test.ts | 39 +++++ __tests__/hooks/use-undo-cancel.test.ts | 136 ++++++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 __tests__/hooks/use-online-status.test.ts create mode 100644 __tests__/hooks/use-page-visibility.test.ts create mode 100644 __tests__/hooks/use-streams-view-preference.test.ts create mode 100644 __tests__/hooks/use-undo-cancel.test.ts 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() + }) +})