Files
tolaria/src/hooks/useCodeMirror.test.ts
2026-05-17 19:34:18 +02:00

167 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { EditorView } from '@codemirror/view'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import { useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
const noop = () => {}
const noopCallbacks: CodeMirrorCallbacks = {
onDocChange: noop,
onCursorActivity: noop,
onSave: noop,
onEscape: () => false,
}
describe('useCodeMirror', () => {
let container: HTMLDivElement
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
document.body.removeChild(container)
})
it('creates an EditorView in the container', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello world', noopCallbacks),
)
expect(result.current.current).not.toBeNull()
expect(container.querySelector('.cm-editor')).toBeInTheDocument()
})
it('does not vertically offset line numbers from editor text', () => {
const ref = { current: container }
renderHook(() =>
useCodeMirror(ref, '---\ntype: Note\n---', noopCallbacks),
)
const gutters = container.querySelector('.cm-gutters')
expect(gutters).toBeInTheDocument()
expect(getComputedStyle(gutters!).paddingTop).toBe('0px')
})
it('tags generated CodeMirror style elements with the runtime CSP nonce', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello world', noopCallbacks),
)
expect(result.current.current?.state.facet(EditorView.cspNonce)).toBe(RUNTIME_STYLE_NONCE)
})
it('enables per-line auto text direction for mixed LTR and RTL content', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'English\nمرحبا بالعالم', noopCallbacks),
)
const view = result.current.current!
expect(view.state.facet(EditorView.perLineTextDirection)).toBe(true)
expect([...container.querySelectorAll('.cm-line')].map(line => line.getAttribute('dir'))).toEqual(['auto', 'auto'])
})
it('calls requestMeasure when laputa-zoom-change event fires', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello', noopCallbacks),
)
const view = result.current.current!
const spy = vi.spyOn(view, 'requestMeasure')
act(() => {
window.dispatchEvent(new Event('laputa-zoom-change'))
})
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
it('stops listening for zoom changes after unmount', () => {
const ref = { current: container }
const { result, unmount } = renderHook(() =>
useCodeMirror(ref, 'hello', noopCallbacks),
)
const view = result.current.current!
const spy = vi.spyOn(view, 'requestMeasure')
unmount()
act(() => {
window.dispatchEvent(new Event('laputa-zoom-change'))
})
// After unmount, the listener should be removed — requestMeasure should NOT be called.
// (The view is also destroyed on unmount, so this verifies cleanup.)
expect(spy).not.toHaveBeenCalled()
spy.mockRestore()
})
it('syncs content prop changes to the editor', () => {
const ref = { current: container }
const onDocChange = vi.fn()
const callbacks = { ...noopCallbacks, onDocChange }
const { result, rerender } = renderHook(
({ content }) => useCodeMirror(ref, content, callbacks),
{ initialProps: { content: '---\ntitle: Hello\n---\nBody' } },
)
const view = result.current.current!
expect(view.state.doc.toString()).toBe('---\ntitle: Hello\n---\nBody')
// Simulate external content update (e.g. frontmatter written to disk)
rerender({ content: '---\ntitle: Hello\nTrashed: true\n---\nBody' })
expect(view.state.doc.toString()).toBe('---\ntitle: Hello\nTrashed: true\n---\nBody')
// External sync should NOT trigger onDocChange (would cause infinite loop)
expect(onDocChange).not.toHaveBeenCalled()
})
it('lets app Escape handling run before the CodeMirror default keymap', () => {
const ref = { current: container }
const onEscape = vi.fn(() => true)
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello', { ...noopCallbacks, onEscape }),
)
const view = result.current.current!
act(() => {
view.focus()
view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Escape',
}))
})
expect(onEscape).toHaveBeenCalledOnce()
})
it('does not sync when content matches current editor state', () => {
const ref = { current: container }
const { result, rerender } = renderHook(
({ content }) => useCodeMirror(ref, content, noopCallbacks),
{ initialProps: { content: 'hello' } },
)
const view = result.current.current!
const spy = vi.spyOn(view, 'dispatch')
// Re-render with same content — no dispatch needed
rerender({ content: 'hello' })
expect(spy).not.toHaveBeenCalled()
spy.mockRestore()
})
it('installs zoomCursorFix that overrides posAtCoords on the view instance', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello world', noopCallbacks),
)
const view = result.current.current!
// The extension overrides posAtCoords on the instance (not the prototype)
expect(Object.prototype.hasOwnProperty.call(view, 'posAtCoords')).toBe(true)
})
})