Files
tolaria/src/hooks/useZoom.test.ts
Test bcfd37d481 fix: CodeMirror cursor placement at non-100% zoom levels
Root cause: CSS zoom on document.documentElement caused stale CodeMirror
measurements. Two issues:

1. Race condition: zoom was applied in useEffect (parent), but CodeMirror
   was created in useEffect (child) — child effects run first, so CM
   measured at zoom=1 before zoom was actually applied.

2. No re-measure on zoom change: CSS zoom changes don't trigger
   ResizeObserver on descendant elements, so CodeMirror never updated
   its cached scaleX/scaleY, line heights, or character widths.

Fix:
- Apply zoom synchronously during useState init (before child effects)
- Dispatch 'laputa-zoom-change' event when zoom changes
- Listen for this event in useCodeMirror and call view.requestMeasure()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:54:50 +01:00

155 lines
5.6 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useZoom } from './useZoom'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
const DEFAULT_VC = { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null } as const
describe('useZoom', () => {
beforeEach(() => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC }, vi.fn())
document.documentElement.style.removeProperty('zoom')
})
it('initializes at 100% by default', () => {
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('restores persisted zoom level from vault config', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn())
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(120)
})
it('defaults to 100 when vault config zoom is null', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: null }, vi.fn())
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('ignores out-of-range persisted values', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 2.0 }, vi.fn())
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('zoomIn increases level by 10', () => {
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomIn())
expect(result.current.zoomLevel).toBe(110)
expect(getVaultConfig().zoom).toBe(1.1)
})
it('zoomOut decreases level by 10', () => {
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomOut())
expect(result.current.zoomLevel).toBe(90)
expect(getVaultConfig().zoom).toBe(0.9)
})
it('zoomIn clamps at 150', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.5 }, vi.fn())
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomIn())
expect(result.current.zoomLevel).toBe(150)
})
it('zoomOut clamps at 80', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 0.8 }, vi.fn())
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomOut())
expect(result.current.zoomLevel).toBe(80)
})
it('zoomReset returns to 100', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.3 }, vi.fn())
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomReset())
expect(result.current.zoomLevel).toBe(100)
expect(getVaultConfig().zoom).toBe(1.0)
})
it('applies CSS zoom property to document element', () => {
const spy = vi.spyOn(document.documentElement.style, 'setProperty')
const { result } = renderHook(() => useZoom())
spy.mockClear() // clear the mount call
act(() => result.current.zoomIn())
expect(spy).toHaveBeenCalledWith('zoom', '110%')
spy.mockRestore()
})
it('zoomIn and zoomOut are stable callbacks', () => {
const { result, rerender } = renderHook(() => useZoom())
const { zoomIn: a, zoomOut: b, zoomReset: c } = result.current
rerender()
expect(result.current.zoomIn).toBe(a)
expect(result.current.zoomOut).toBe(b)
expect(result.current.zoomReset).toBe(c)
})
it('successive zoomIn calls accumulate', () => {
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomIn())
act(() => result.current.zoomIn())
act(() => result.current.zoomIn())
expect(result.current.zoomLevel).toBe(130)
})
it('defaults to 100 when vault config store is empty', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC }, vi.fn())
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('dispatches laputa-zoom-change event on zoomIn', () => {
const handler = vi.fn()
window.addEventListener('laputa-zoom-change', handler)
const { result } = renderHook(() => useZoom())
handler.mockClear() // clear any init-phase dispatches
act(() => result.current.zoomIn())
expect(handler).toHaveBeenCalled()
window.removeEventListener('laputa-zoom-change', handler)
})
it('dispatches laputa-zoom-change event on zoomOut', () => {
const handler = vi.fn()
window.addEventListener('laputa-zoom-change', handler)
const { result } = renderHook(() => useZoom())
handler.mockClear()
act(() => result.current.zoomOut())
expect(handler).toHaveBeenCalled()
window.removeEventListener('laputa-zoom-change', handler)
})
it('dispatches laputa-zoom-change event on zoomReset', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn())
const handler = vi.fn()
window.addEventListener('laputa-zoom-change', handler)
const { result } = renderHook(() => useZoom())
handler.mockClear()
act(() => result.current.zoomReset())
expect(handler).toHaveBeenCalled()
window.removeEventListener('laputa-zoom-change', handler)
})
it('applies CSS zoom synchronously during initialization', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn())
const spy = vi.spyOn(document.documentElement.style, 'setProperty')
renderHook(() => useZoom())
// Zoom should be applied during state init (setProperty called with zoom value)
expect(spy).toHaveBeenCalledWith('zoom', '120%')
spy.mockRestore()
})
})