Files
tolaria/src/hooks/useNavigationHistory.test.ts
Luca Rossi e58f521262 fix: back/forward nav arrows reopen replaced tabs instead of skipping them (#172)
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>
2026-03-02 11:00:41 +01:00

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)
})
})