import { render, act, cleanup } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import type { BookNote } from '@/types/book'; // ---------- Shared mutable test state (captured by the mock factories) ---------- let scrollToIndexSpy: Mock<(arg: unknown) => void>; // Only the FIRST (mount-time) `initialized` callback is captured, mirroring how // OverlayScrollbars binds its event handlers when it initializes the viewport. // A fix that relied on a fresher render closure would pass against the latest // callback but still break in the real app — so the test forces a ref-based fix. let capturedInitialized: | ((instance: { elements: () => { viewport: HTMLElement } }) => void) | undefined; // Latest props Virtuoso was rendered with, so tests can assert the mount-time // position (initialTopMostItemIndex) the panel hands it. let capturedVirtuosoProps: Record | undefined; let mockProgress: { location: string } | null; let mockBooknotes: BookNote[]; // ---------- Mocks ---------- // Production code uses per-field selectors; mock must apply them. vi.mock('@/store/bookDataStore', () => { const state = { getConfig: () => ({ booknotes: mockBooknotes }) }; return { useBookDataStore: (selector?: (s: typeof state) => R) => selector ? selector(state) : state, }; }); vi.mock('@/store/readerStore', () => { const state = { getProgress: () => mockProgress }; return { useReaderStore: (selector?: (s: typeof state) => R) => (selector ? selector(state) : state), }; }); vi.mock('@/store/sidebarStore', () => ({ useSidebarStore: () => ({ setActiveBooknoteType: vi.fn(), setBooknoteResults: vi.fn(), }), })); // Derive a per-chapter TOC group from the spine step of each note's CFI so the // flattened list is [header, note, header, note, ...] sorted by chapter. vi.mock('@/services/nav', () => ({ findTocItemBS: (_toc: unknown, cfi: string) => { const match = cfi.match(/\/6\/(\d+)!/); const n = match ? Number(match[1]) : 0; return { id: n, href: `ch${n}.html`, label: `Chapter ${n}`, index: n }; }, })); vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (s: string) => s, })); vi.mock('@/utils/event', () => ({ eventDispatcher: { dispatch: vi.fn(), on: vi.fn(), off: vi.fn() }, })); vi.mock('@/app/reader/components/sidebar/BooknoteItem', () => ({ default: () => null, })); vi.mock('@/app/reader/components/EmptyState', () => ({ default: () => null, })); // Virtuoso is replaced with a stub that exposes a spy-able `scrollToIndex` // through the imperative handle and hands BooknoteView a scroller element. vi.mock('react-virtuoso', async () => { const ReactMod = await import('react'); return { Virtuoso: ReactMod.forwardRef( ( props: { scrollerRef?: (el: HTMLElement | Window | null) => void }, ref: React.Ref, ) => { capturedVirtuosoProps = props as Record; ReactMod.useImperativeHandle(ref, () => ({ scrollToIndex: (arg: unknown) => scrollToIndexSpy(arg), })); ReactMod.useEffect(() => { props.scrollerRef?.(document.createElement('div')); }, []); return null; }, ), }; }); // Capture the OverlayScrollbars `initialized` callback so the test can fire it // on demand (the real hook fires it after a deferred, timing-dependent init). vi.mock('overlayscrollbars-react', () => ({ useOverlayScrollbars: (opts: { events?: { initialized?: (i: { elements: () => { viewport: HTMLElement } }) => void }; }) => { if (!capturedInitialized) capturedInitialized = opts.events?.initialized; return [vi.fn(), () => undefined]; }, })); // eslint-disable-next-line import/first import BooknoteView from '@/app/reader/components/sidebar/BooknoteView'; const makeNote = (cfi: string): BookNote => ({ id: cfi, type: 'annotation', cfi, text: cfi, note: '', createdAt: 0, updatedAt: 0, }) as BookNote; const fireOverlayScrollbarsInitialized = () => { act(() => { capturedInitialized?.({ elements: () => ({ viewport: document.createElement('div') }) }); }); }; beforeEach(() => { scrollToIndexSpy = vi.fn<(arg: unknown) => void>(); capturedInitialized = undefined; capturedVirtuosoProps = undefined; mockProgress = null; mockBooknotes = [ makeNote('epubcfi(/6/4!/4/2:0)'), makeNote('epubcfi(/6/6!/4/4:0)'), makeNote('epubcfi(/6/8!/4/2:0)'), makeNote('epubcfi(/6/10!/4/6:0)'), makeNote('epubcfi(/6/26!/4/2:0)'), ]; // Run rAF synchronously so the callback's scroll happens inside act(). vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0); return 0; }); }); afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); describe('BooknoteView — OverlayScrollbars init does not rewind the list to the top', () => { it('re-applies the auto-scroll to the nearest note when OverlayScrollbars initializes after the reading position arrives', () => { // Fresh open: BooknoteView mounts before the first relocate, so `progress` // (and thus the nearest cfi) has no reading position yet. mockProgress = null; const { rerender } = render(); // The relocate arrives → the normal auto-scroll effect centers the nearest // note. The list is [h4, n4, h6, n6, h8, n8, h10, n10, h26, n26]; the // reading position is in the last chapter, so the nearest note is index 9. mockProgress = { location: 'epubcfi(/6/26!/4/10:0)' }; act(() => { rerender(); }); // Ignore that first scroll; we only care whether the OverlayScrollbars init // (which clobbers scrollTop) re-applies it instead of stranding the top. scrollToIndexSpy.mockClear(); fireOverlayScrollbarsInitialized(); expect(scrollToIndexSpy).toHaveBeenCalledWith(expect.objectContaining({ index: 9 })); }); it('does not force a scroll on OverlayScrollbars init when there is no reading position', () => { mockProgress = null; render(); scrollToIndexSpy.mockClear(); fireOverlayScrollbarsInitialized(); expect(scrollToIndexSpy).not.toHaveBeenCalled(); }); it('positions Virtuoso natively on mount (without a racing scrollToIndex) when the reading position is already known', () => { // Switching to the panel while reading: the reading position is available at // mount. A scrollToIndex against the freshly mounted, unmeasured list no-ops // or wedges it into rendering nothing — so the panel must mount Virtuoso // already centered via initialTopMostItemIndex and the scroll effect must // skip its first jump. mockProgress = { location: 'epubcfi(/6/26!/4/10:0)' }; act(() => { render(); }); expect(capturedVirtuosoProps?.['initialTopMostItemIndex']).toEqual({ index: 9, align: 'center', }); expect(scrollToIndexSpy).not.toHaveBeenCalled(); // The deferred OverlayScrollbars init resets scrollTop; the re-apply then // restores the centered position (now that the rows have been measured). fireOverlayScrollbarsInitialized(); expect(scrollToIndexSpy).toHaveBeenCalledWith(expect.objectContaining({ index: 9 })); }); it('jumps instantly (behavior auto) for a far scroll instead of animating it, like TOCView', () => { // 12 notes in distinct chapters → flat list [h, n, h, n, ...] of 24 rows; // the nearest note (last chapter) sits at index 23, far from the top. mockBooknotes = Array.from({ length: 12 }, (_, i) => makeNote(`epubcfi(/6/${4 + i * 2}!/4/2:0)`), ); // Reload-style: no reading position at mount, so initialTopMostItemIndex // does not handle it and the scroll effect performs the jump. mockProgress = null; const { rerender } = render(); mockProgress = { location: 'epubcfi(/6/26!/4/10:0)' }; act(() => { rerender(); }); // distance (23 - 0) > 16 → instant jump, not a smooth animation. expect(scrollToIndexSpy).toHaveBeenCalledWith( expect.objectContaining({ index: 23, behavior: 'auto' }), ); expect(scrollToIndexSpy).not.toHaveBeenCalledWith( expect.objectContaining({ behavior: 'smooth' }), ); }); });