Files
tolaria/src/hooks/useNavigationHistory.test.ts

184 lines
6.9 KiB
TypeScript
Raw Normal View History

feat: back/forward navigation between opened notes (#75) * feat: add useNavigationHistory hook for browser-style back/forward Pure state management hook that tracks a navigation stack of note paths with cursor-based back/forward traversal. Handles edge cases: duplicate pushes (no-op), forward stack cleared on new push, invalid path skipping, and path removal when tabs close. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: integrate back/forward navigation into UI and keyboard shortcuts - Add Back/Forward arrow buttons to TabBar (left of tabs) - Wire useNavigationHistory through App → Editor → TabBar - Add Cmd+[ and Cmd+] keyboard shortcuts via useAppKeyboard - Register Go Back/Go Forward in command palette - History tracks active tab changes, skips closed tabs gracefully - Navigation from history doesn't re-push to stack (ref guard) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add mouse button 3/4 and trackpad swipe for back/forward - Mouse buttons 3 (back) and 4 (forward) trigger navigation - macOS trackpad two-finger horizontal swipe triggers back/forward using accumulated wheel deltaX with a 120px threshold - Debounced reset after 300ms of inactivity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: add back-forward-nav.pen with 3 state frames Shows: default (both disabled), one note visited (back disabled), two notes visited (back enabled). Matches tab bar button placement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:39:12 +01:00
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useNavigationHistory } from './useNavigationHistory'
describe('useNavigationHistory', () => {
it('starts with empty state', () => {
const { result } = renderHook(() => useNavigationHistory())
expect(result.current.canGoBack).toBe(false)
expect(result.current.canGoForward).toBe(false)
})
it('push adds path to history', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => result.current.push('/a'))
expect(result.current.canGoBack).toBe(false)
expect(result.current.canGoForward).toBe(false)
})
it('back returns previous path after two pushes', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => { result.current.push('/a'); result.current.push('/b') })
expect(result.current.canGoBack).toBe(true)
let target: string | null = null
act(() => { target = result.current.goBack() })
expect(target).toBe('/a')
expect(result.current.canGoBack).toBe(false)
expect(result.current.canGoForward).toBe(true)
})
it('forward returns next path after going back', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => { result.current.push('/a'); result.current.push('/b') })
act(() => { result.current.goBack() })
let target: string | null = null
act(() => { target = result.current.goForward() })
expect(target).toBe('/b')
expect(result.current.canGoForward).toBe(false)
})
it('push after back clears forward stack', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => { result.current.push('/a'); result.current.push('/b'); result.current.push('/c') })
act(() => { result.current.goBack() })
expect(result.current.canGoForward).toBe(true)
act(() => { result.current.push('/d') })
expect(result.current.canGoForward).toBe(false)
let target: string | null = null
act(() => { target = result.current.goBack() })
expect(target).toBe('/b')
})
it('duplicate push is a no-op', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => { result.current.push('/a'); result.current.push('/a') })
expect(result.current.canGoBack).toBe(false)
})
it('goBack skips invalid paths', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => { result.current.push('/a'); result.current.push('/b'); result.current.push('/c') })
let target: string | null = null
act(() => { target = result.current.goBack((p) => p !== '/b') })
expect(target).toBe('/a')
})
it('goForward skips invalid paths', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => { result.current.push('/a'); result.current.push('/b'); result.current.push('/c') })
act(() => { result.current.goBack(); result.current.goBack() })
let target: string | null = null
act(() => { target = result.current.goForward((p) => p !== '/b') })
expect(target).toBe('/c')
})
it('goBack returns null when nothing valid remains', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => { result.current.push('/a') })
let target: string | null = 'should-be-null'
act(() => { target = result.current.goBack() })
expect(target).toBeNull()
})
it('goForward returns null at end of history', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => { result.current.push('/a') })
let target: string | null = 'should-be-null'
act(() => { target = result.current.goForward() })
expect(target).toBeNull()
})
it('removePath adjusts cursor correctly', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => { result.current.push('/a'); result.current.push('/b'); result.current.push('/c') })
act(() => { result.current.removePath('/b') })
let target: string | null = null
act(() => { target = result.current.goBack() })
expect(target).toBe('/a')
})
it('removePath when current note is removed', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => { result.current.push('/a'); result.current.push('/b') })
act(() => { result.current.removePath('/b') })
// After removing /b (cursor was at index 1), cursor should adjust
expect(result.current.canGoBack).toBe(false)
expect(result.current.canGoForward).toBe(false)
})
it('goBack without predicate returns closed-tab paths (replace scenario)', () => {
const { result } = renderHook(() => useNavigationHistory())
// Simulate: open A, then B replaces A, then C replaces B
act(() => { result.current.push('/a'); result.current.push('/b'); result.current.push('/c') })
// Without a predicate, goBack returns /b even though its tab was replaced
let target: string | null = null
act(() => { target = result.current.goBack() })
expect(target).toBe('/b')
act(() => { target = result.current.goBack() })
expect(target).toBe('/a')
})
it('goBack with entry-exists predicate skips deleted notes but returns replaced tabs', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => { result.current.push('/a'); result.current.push('/deleted'); result.current.push('/c') })
// Simulate: /deleted was removed from vault, but /a still exists
const vaultPaths = new Set(['/a', '/c'])
const isEntryExists = (p: string) => vaultPaths.has(p)
let target: string | null = null
act(() => { target = result.current.goBack(isEntryExists) })
// Should skip /deleted and return /a
expect(target).toBe('/a')
})
it('goForward with entry-exists predicate skips deleted notes', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => { result.current.push('/a'); result.current.push('/deleted'); result.current.push('/c') })
act(() => { result.current.goBack(); result.current.goBack() })
const vaultPaths = new Set(['/a', '/c'])
const isEntryExists = (p: string) => vaultPaths.has(p)
let target: string | null = null
act(() => { target = result.current.goForward(isEntryExists) })
// Should skip /deleted and return /c
expect(target).toBe('/c')
})
feat: back/forward navigation between opened notes (#75) * feat: add useNavigationHistory hook for browser-style back/forward Pure state management hook that tracks a navigation stack of note paths with cursor-based back/forward traversal. Handles edge cases: duplicate pushes (no-op), forward stack cleared on new push, invalid path skipping, and path removal when tabs close. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: integrate back/forward navigation into UI and keyboard shortcuts - Add Back/Forward arrow buttons to TabBar (left of tabs) - Wire useNavigationHistory through App → Editor → TabBar - Add Cmd+[ and Cmd+] keyboard shortcuts via useAppKeyboard - Register Go Back/Go Forward in command palette - History tracks active tab changes, skips closed tabs gracefully - Navigation from history doesn't re-push to stack (ref guard) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add mouse button 3/4 and trackpad swipe for back/forward - Mouse buttons 3 (back) and 4 (forward) trigger navigation - macOS trackpad two-finger horizontal swipe triggers back/forward using accumulated wheel deltaX with a 120px threshold - Debounced reset after 300ms of inactivity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: add back-forward-nav.pen with 3 state frames Shows: default (both disabled), one note visited (back disabled), two notes visited (back enabled). Matches tab bar button placement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:39:12 +01:00
it('handles long navigation chain', () => {
const { result } = renderHook(() => useNavigationHistory())
act(() => {
for (let i = 0; i < 10; i++) result.current.push(`/${i}`)
})
expect(result.current.canGoBack).toBe(true)
// Go all the way back
for (let i = 8; i >= 0; i--) {
let target: string | null = null
act(() => { target = result.current.goBack() })
expect(target).toBe(`/${i}`)
}
expect(result.current.canGoBack).toBe(false)
// Go all the way forward
for (let i = 1; i <= 9; i++) {
let target: string | null = null
act(() => { target = result.current.goForward() })
expect(target).toBe(`/${i}`)
}
expect(result.current.canGoForward).toBe(false)
})
})