Files
tolaria/src/hooks/useHeadingTitleSync.test.ts
lucaronin a1ece6f395 feat: sync note title with H1 heading (predictive title)
When the editor's first block is an H1 heading, the note title
automatically stays in sync. Changes are debounced at 500ms.
Manual rename via tab breaks the sync until the tab is switched.

- Show H1 in editor (stop stripping on load, stop reconstructing on save)
- New useHeadingTitleSync hook tracks sync state and debounces title updates
- handleEditorChange keeps frontmatter title: in sync with H1
- Fast path for H1-only content preserves instant new-note interactivity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:59:05 +01:00

154 lines
5.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useHeadingTitleSync } from './useHeadingTitleSync'
describe('useHeadingTitleSync', () => {
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('calls onTitleSync after debounce when H1 differs from title', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old Title', onTitleSync })
)
act(() => { result.current.onH1Changed('New Title') })
expect(onTitleSync).not.toHaveBeenCalled()
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'New Title')
})
it('does not call onTitleSync when H1 matches current title', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Same Title', onTitleSync })
)
act(() => { result.current.onH1Changed('Same Title') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).not.toHaveBeenCalled()
})
it('does not call onTitleSync when H1 is null', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync })
)
act(() => { result.current.onH1Changed(null) })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).not.toHaveBeenCalled()
})
it('debounces rapid H1 changes and uses last value', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old', onTitleSync })
)
act(() => { result.current.onH1Changed('New 1') })
act(() => { vi.advanceTimersByTime(200) })
act(() => { result.current.onH1Changed('New 2') })
act(() => { vi.advanceTimersByTime(200) })
act(() => { result.current.onH1Changed('New 3') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).toHaveBeenCalledTimes(1)
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'New 3')
})
it('does not call onTitleSync when no active tab', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: null, currentTitle: null, onTitleSync })
)
act(() => { result.current.onH1Changed('Title') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).not.toHaveBeenCalled()
})
it('resets sync state when active tab changes', () => {
const onTitleSync = vi.fn()
const { result, rerender } = renderHook(
({ path, title }) => useHeadingTitleSync({ activeTabPath: path, currentTitle: title, onTitleSync }),
{ initialProps: { path: '/a.md', title: 'A' } }
)
// Break sync on tab A
act(() => { result.current.onManualRename('Custom', 'A H1') })
// H1 change should be ignored (sync broken)
act(() => { result.current.onH1Changed('New A') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).not.toHaveBeenCalled()
// Switch to tab B — sync resets
rerender({ path: '/b.md', title: 'B' })
// H1 change should now work
act(() => { result.current.onH1Changed('New B') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).toHaveBeenCalledWith('/b.md', 'New B')
})
it('breaks sync when manual rename differs from H1', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync })
)
act(() => { result.current.onManualRename('Custom Name', 'Title') })
// H1 changes should be ignored
act(() => { result.current.onH1Changed('New H1') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).not.toHaveBeenCalled()
})
it('does not break sync when manual rename matches H1', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old', onTitleSync })
)
act(() => { result.current.onManualRename('Same as H1', 'Same as H1') })
// Sync should still be active
act(() => { result.current.onH1Changed('Updated') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'Updated')
})
it('does not break sync when H1 is null during manual rename', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync })
)
act(() => { result.current.onManualRename('New Name', null) })
// Sync should still be active (no H1 to compare)
act(() => { result.current.onH1Changed('H1 Text') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'H1 Text')
})
it('clears pending debounce timer on tab switch', () => {
const onTitleSync = vi.fn()
const { result, rerender } = renderHook(
({ path, title }) => useHeadingTitleSync({ activeTabPath: path, currentTitle: title, onTitleSync }),
{ initialProps: { path: '/a.md', title: 'A' } }
)
act(() => { result.current.onH1Changed('New A') })
// Switch tab before debounce fires
rerender({ path: '/b.md', title: 'B' })
act(() => { vi.advanceTimersByTime(500) })
// Should NOT fire for old tab
expect(onTitleSync).not.toHaveBeenCalled()
})
})