handleReplaceActiveTab removes the old tab from the tabs array, but the old path remained in the navigation history. goBack(isTabOpen) then skipped it because the tab was no longer open, returning null and making the buttons appear broken. Fix: filter by vault entry existence (not tab-open state) and reopen closed tabs via handleSelectNote when navigating back/forward. Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
184 lines
6.9 KiB
TypeScript
184 lines
6.9 KiB
TypeScript
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')
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|