Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions __tests__/hooks/use-online-status.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
81 changes: 81 additions & 0 deletions __tests__/hooks/use-page-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
39 changes: 39 additions & 0 deletions __tests__/hooks/use-streams-view-preference.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
136 changes: 136 additions & 0 deletions __tests__/hooks/use-undo-cancel.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>; info: ReturnType<typeof vi.fn> }
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<typeof useContract>)
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()
})
})