File size: 3,819 Bytes
3201ca6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import type { RefObject } from "react";
/**
* Creates a mock scroll element with a trackable scrollTop setter.
*
* state.scrollTop can be set directly (bypassing the spy) to position
* the element for onChatBodyScroll calls without polluting the spy.
*/
function createMockScrollElement(initialScrollHeight = 1000) {
const state = {
scrollTop: 0,
scrollHeight: initialScrollHeight,
clientHeight: 500,
};
const scrollTopSetter = vi.fn((value: number) => {
state.scrollTop = value;
});
const element = {
get scrollTop() {
return state.scrollTop;
},
set scrollTop(value: number) {
scrollTopSetter(value);
},
get scrollHeight() {
return state.scrollHeight;
},
get clientHeight() {
return state.clientHeight;
},
} as unknown as HTMLDivElement;
return { element, scrollTopSetter, state };
}
describe("useScrollToBottom", () => {
let mock: ReturnType<typeof createMockScrollElement>;
let ref: RefObject<HTMLDivElement>;
beforeEach(() => {
mock = createMockScrollElement(1000);
ref = { current: mock.element } as RefObject<HTMLDivElement>;
});
describe("no automatic scrolling on render", () => {
it("does NOT scroll on initial render", () => {
renderHook(() => useScrollToBottom(ref));
// No useLayoutEffect means no automatic scroll-to-bottom
expect(mock.scrollTopSetter).not.toHaveBeenCalled();
});
it("does NOT scroll when re-rendered (e.g., during resize)", () => {
const { rerender } = renderHook(() => useScrollToBottom(ref));
mock.state.scrollHeight = 1500;
rerender();
expect(mock.scrollTopSetter).not.toHaveBeenCalled();
});
});
describe("scroll position tracking", () => {
it("tracks hitBottom correctly via onChatBodyScroll", () => {
const { result } = renderHook(() => useScrollToBottom(ref));
// Position at bottom: scrollTop(480) + clientHeight(500) = 980 >= 1000 - 20
mock.state.scrollTop = 480;
act(() => {
result.current.onChatBodyScroll(mock.element);
});
expect(result.current.hitBottom).toBe(true);
// Position not at bottom: scrollTop(200) + clientHeight(500) = 700 < 980
mock.state.scrollTop = 200;
act(() => {
result.current.onChatBodyScroll(mock.element);
});
expect(result.current.hitBottom).toBe(false);
});
it("disables autoScroll when user scrolls up", () => {
const { result } = renderHook(() => useScrollToBottom(ref));
// First scroll to establish prevScrollTopRef
mock.state.scrollTop = 400;
act(() => {
result.current.onChatBodyScroll(mock.element);
});
// Scroll up (lower scrollTop than previous)
mock.state.scrollTop = 200;
act(() => {
result.current.onChatBodyScroll(mock.element);
});
expect(result.current.autoScroll).toBe(false);
});
it("re-enables autoScroll when user reaches bottom", () => {
const { result } = renderHook(() => useScrollToBottom(ref));
// Scroll up to disable autoScroll
mock.state.scrollTop = 400;
act(() => {
result.current.onChatBodyScroll(mock.element);
});
mock.state.scrollTop = 200;
act(() => {
result.current.onChatBodyScroll(mock.element);
});
expect(result.current.autoScroll).toBe(false);
// Scroll to bottom
mock.state.scrollTop = 500; // 500 + 500 = 1000 >= 980
act(() => {
result.current.onChatBodyScroll(mock.element);
});
expect(result.current.autoScroll).toBe(true);
});
});
});
|