import { describe, test, expect, vi, beforeEach, afterEach, type MockInstance, } from 'vitest' import { createRef } from 'react' import { act, render, screen } from '@testing-library/react' import { ChatContent } from './chat-content' import type { ChatContentRef } from '../types' type IOCallback = (entries: IntersectionObserverEntry[]) => void interface FakeObserver { callback: IOCallback observed: Element[] disconnected: boolean } interface FakeMutationObserverInstance { callback: MutationCallback target: Node | null disconnected: boolean } let observers: FakeObserver[] = [] let mutationObservers: FakeMutationObserverInstance[] = [] let scrollToSpy: MockInstance // Mutable "DOM measurements" the prototype getters read from. let scrollHeightValue = 0 let scrollTopValue = 0 let clientHeightValue = 0 class FakeIntersectionObserver { _obs: FakeObserver constructor(cb: IOCallback) { this._obs = { callback: cb, observed: [], disconnected: false } observers.push(this._obs) } observe(el: Element) { this._obs.observed.push(el) } unobserve() { // NOOP } disconnect() { this._obs.disconnected = true } takeRecords(): IntersectionObserverEntry[] { return [] } } class FakeMutationObserver { _inst: FakeMutationObserverInstance constructor(cb: MutationCallback) { this._inst = { callback: cb, target: null, disconnected: false } mutationObservers.push(this._inst) } observe(target: Node) { this._inst.target = target } disconnect() { this._inst.disconnected = true } takeRecords(): MutationRecord[] { return [] } } /** * The component creates the top observer first, then the bottom one — so * `observers[0]` is top and `observers[1]` is bottom. Helper trips one of them. */ function fireIntersection(index: 0 | 1, isIntersecting: boolean) { const obs = observers[index] if (!obs) throw new Error(`No observer at index ${index}`) act(() => { obs.callback([ { isIntersecting, target: obs.observed[0]! } as IntersectionObserverEntry, ]) }) } /** Triggers the most recent MutationObserver as if a DOM mutation happened. */ function fireMutation() { const obs = mutationObservers.at(-1) if (!obs) throw new Error('No MutationObserver registered') act(() => { obs.callback([], obs as unknown as MutationObserver) }) } describe('ChatContent', () => { beforeEach(() => { observers = [] mutationObservers = [] scrollHeightValue = 0 scrollTopValue = 0 clientHeightValue = 0 vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) vi.stubGlobal('MutationObserver', FakeMutationObserver) // Run rAF synchronously so MutationObserver-driven scroll checks happen // inside `act`. vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { cb(0) return 1 }) vi.stubGlobal('cancelAnimationFrame', () => { // NOOP }) scrollToSpy = vi .spyOn(Element.prototype, 'scrollTo') .mockImplementation(() => { // NOOP }) vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation( () => scrollHeightValue, ) vi.spyOn(HTMLElement.prototype, 'scrollTop', 'get').mockImplementation( () => scrollTopValue, ) vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation( () => clientHeightValue, ) }) afterEach(() => { vi.unstubAllGlobals() vi.restoreAllMocks() }) test('renders children', () => { render(Scrollable content) expect(screen.getByText('Scrollable content')).toBeTruthy() }) test('observes the top and bottom sentinels', () => { render(content) expect(observers).toHaveLength(2) expect(observers[0]?.observed).toHaveLength(1) expect(observers[1]?.observed).toHaveLength(1) }) test('ref starts with isAtTop and isAtBottom both true', () => { const ref = createRef() render(content) expect(ref.current?.isAtTop).toBe(true) expect(ref.current?.isAtBottom).toBe(true) }) test('isAtBottom flips when the bottom sentinel leaves the viewport', () => { const ref = createRef() render(content) fireIntersection(1, false) expect(ref.current?.isAtBottom).toBe(false) fireIntersection(1, true) expect(ref.current?.isAtBottom).toBe(true) }) test('isAtTop flips when the top sentinel leaves the viewport', () => { const ref = createRef() render(content) fireIntersection(0, false) expect(ref.current?.isAtTop).toBe(false) fireIntersection(0, true) expect(ref.current?.isAtTop).toBe(true) }) test('scrollToBottom() scrolls to scrollHeight with smooth behaviour', () => { scrollHeightValue = 1000 const ref = createRef() render(content) act(() => ref.current?.scrollToBottom()) expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth', }) }) test('scrollToTop() scrolls the container to top 0', () => { const ref = createRef() render(content) act(() => ref.current?.scrollToTop()) expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: 'smooth', }) }) test('clicking the jump-to-latest FAB scrolls to the bottom', () => { render(content) fireIntersection(1, false) // not at bottom anymore const fab = screen.getByLabelText('Jump to latest') act(() => fab.click()) expect(scrollToSpy).toHaveBeenCalled() }) test('uses custom jumpToLatest label', () => { render( x, ) expect(screen.getByLabelText('Ir al final')).toBeTruthy() }) test('disconnects observers on unmount', () => { const { unmount } = render(content) unmount() expect(observers[0]?.disconnected).toBe(true) expect(observers[1]?.disconnected).toBe(true) }) describe('autoScroll', () => { test('attaches a MutationObserver by default', () => { render(content) expect(mutationObservers).toHaveLength(1) }) test('does not attach a MutationObserver when autoScroll={false}', () => { render(content) expect(mutationObservers).toHaveLength(0) }) test('scrolls to the new bottom when content grows and the user is at the bottom', () => { // At-bottom: scrollHeight - scrollTop - clientHeight = 0 scrollHeightValue = 1000 clientHeightValue = 500 scrollTopValue = 500 render(content) expect(mutationObservers).toHaveLength(1) // Content grows by 200px while the user is still at the bottom. scrollHeightValue = 1200 fireMutation() expect(scrollToSpy).toHaveBeenCalledWith({ top: 1200, behavior: 'smooth', }) }) test('does NOT scroll when the user has scrolled up to read history', () => { // 400px from the bottom: 1000 - 100 - 500 = 400 (well past the 32px slack) scrollHeightValue = 1000 clientHeightValue = 500 scrollTopValue = 100 render(content) // Content grows by 200px — distance-from-bottom is now 600, growth is // 200, so 600 > 200 + 32 → leave the user alone. scrollHeightValue = 1200 fireMutation() expect(scrollToSpy).not.toHaveBeenCalled() }) test('disconnects the MutationObserver on unmount', () => { const { unmount } = render(content) unmount() expect(mutationObservers[0]?.disconnected).toBe(true) }) }) })