diff --git a/hooks/useExpandableText.test.ts b/hooks/useExpandableText.test.ts new file mode 100644 index 00000000..65b074c2 --- /dev/null +++ b/hooks/useExpandableText.test.ts @@ -0,0 +1,196 @@ +import { act, renderHook } from "@testing-library/react"; +import { useExpandableText } from "./useExpandableText"; + +type WritableRef = { + current: T; +}; + +function setElementHeights( + element: HTMLDivElement, + { scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number }, +) { + Object.defineProperty(element, "scrollHeight", { + value: scrollHeight, + configurable: true, + }); + + Object.defineProperty(element, "clientHeight", { + value: clientHeight, + configurable: true, + }); +} + +function attachMeasuredElement( + ref: WritableRef, + heights: { scrollHeight: number; clientHeight: number }, +) { + const element = document.createElement("div"); + + setElementHeights(element, heights); + ref.current = element; + + return element; +} + +describe("useExpandableText", () => { + const originalResizeObserver = global.ResizeObserver; + + let resizeObserverCallback: ResizeObserverCallback = () => undefined; + let observe: jest.Mock; + let disconnect: jest.Mock; + + beforeEach(() => { + resizeObserverCallback = () => undefined; + observe = jest.fn(); + disconnect = jest.fn(); + + global.ResizeObserver = jest.fn().mockImplementation((callback: ResizeObserverCallback) => { + resizeObserverCallback = callback; + + return { + observe, + unobserve: jest.fn(), + disconnect, + }; + }) as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + jest.restoreAllMocks(); + + if (originalResizeObserver) { + global.ResizeObserver = originalResizeObserver; + } else { + delete (global as Partial).ResizeObserver; + } + }); + + test("scrollHeight가 clientHeight + 1을 초과할 때만 overflow를 true로 판단한다", () => { + const { result, rerender } = renderHook( + ({ content }) => useExpandableText({ content }), + { initialProps: { content: "first" } }, + ); + + const contentRef = result.current.contentRef as WritableRef; + + const element = attachMeasuredElement(contentRef, { + scrollHeight: 101, + clientHeight: 100, + }); + + rerender({ content: "second" }); + + expect(result.current.isOverflow).toBe(false); + + setElementHeights(element, { + scrollHeight: 102, + clientHeight: 100, + }); + + rerender({ content: "third" }); + + expect(result.current.isOverflow).toBe(true); + expect(observe).toHaveBeenCalledWith(element); + }); + + test("toggleExpanded를 호출하면 펼침 상태가 토글된다", () => { + const { result } = renderHook(() => useExpandableText({ content: "body" })); + + expect(result.current.isExpanded).toBe(false); + + act(() => { + result.current.toggleExpanded(); + }); + + expect(result.current.isExpanded).toBe(true); + + act(() => { + result.current.toggleExpanded(); + }); + + expect(result.current.isExpanded).toBe(false); + }); + + test("접힌 상태에서는 ResizeObserver가 overflow를 다시 계산한다", () => { + const { result, rerender } = renderHook( + ({ content }) => useExpandableText({ content }), + { initialProps: { content: "first" } }, + ); + + const contentRef = result.current.contentRef as WritableRef; + + const element = attachMeasuredElement(contentRef, { + scrollHeight: 100, + clientHeight: 100, + }); + + rerender({ content: "second" }); + + expect(result.current.isOverflow).toBe(false); + + setElementHeights(element, { + scrollHeight: 140, + clientHeight: 100, + }); + + act(() => { + resizeObserverCallback([] as ResizeObserverEntry[], {} as ResizeObserver); + }); + + expect(result.current.isOverflow).toBe(true); + }); + + test("펼친 상태에서는 ResizeObserver가 overflow를 다시 계산하지 않는다", () => { + const { result, rerender } = renderHook( + ({ content }) => useExpandableText({ content }), + { initialProps: { content: "first" } }, + ); + + const contentRef = result.current.contentRef as WritableRef; + + const element = attachMeasuredElement(contentRef, { + scrollHeight: 100, + clientHeight: 100, + }); + + rerender({ content: "second" }); + + expect(result.current.isOverflow).toBe(false); + + act(() => { + result.current.toggleExpanded(); + }); + + setElementHeights(element, { + scrollHeight: 140, + clientHeight: 100, + }); + + act(() => { + resizeObserverCallback([] as ResizeObserverEntry[], {} as ResizeObserver); + }); + + expect(result.current.isExpanded).toBe(true); + expect(result.current.isOverflow).toBe(false); + }); + + test("언마운트 시 ResizeObserver를 disconnect 한다", () => { + const { result, rerender, unmount } = renderHook( + ({ content }) => useExpandableText({ content }), + { initialProps: { content: "first" } }, + ); + + const contentRef = result.current.contentRef as WritableRef; + + attachMeasuredElement(contentRef, { + scrollHeight: 100, + clientHeight: 100, + }); + + rerender({ content: "second" }); + + unmount(); + + expect(disconnect).toHaveBeenCalled(); + }); +}); diff --git a/hooks/useScrollFloatingVisibility.test.ts b/hooks/useScrollFloatingVisibility.test.ts new file mode 100644 index 00000000..5e3a878c --- /dev/null +++ b/hooks/useScrollFloatingVisibility.test.ts @@ -0,0 +1,350 @@ +import { act, renderHook } from "@testing-library/react"; +import { createRef } from "react"; +import useScrollFloatingVisibility from "./useScrollFloatingVisibility"; + +type ScrollTarget = Window | HTMLElement; + +const originalScrollYDescriptor = Object.getOwnPropertyDescriptor(window, "scrollY"); +const originalPageYOffsetDescriptor = Object.getOwnPropertyDescriptor(window, "pageYOffset"); + +function setWindowScroll(value: number) { + Object.defineProperty(window, "scrollY", { + value, + configurable: true, + writable: true, + }); + Object.defineProperty(window, "pageYOffset", { + value, + configurable: true, + writable: true, + }); +} + +function restoreWindowScrollProperties() { + if (originalScrollYDescriptor) { + Object.defineProperty(window, "scrollY", originalScrollYDescriptor); + } else { + Reflect.deleteProperty(window, "scrollY"); + } + + if (originalPageYOffsetDescriptor) { + Object.defineProperty(window, "pageYOffset", originalPageYOffsetDescriptor); + } else { + Reflect.deleteProperty(window, "pageYOffset"); + } +} + +function setElementScroll(target: HTMLElement, value: number) { + Object.defineProperty(target, "scrollTop", { + value, + configurable: true, + writable: true, + }); +} + +function fireScroll(target: ScrollTarget = window) { + target.dispatchEvent(new Event("scroll")); +} + +function createRect(top: number): DOMRect { + return { + top, + bottom: top + 10, + left: 0, + right: 0, + width: 100, + height: 10, + x: 0, + y: top, + toJSON: () => ({}), + } as DOMRect; +} + +describe("useScrollFloatingVisibility", () => { + let rafQueue: Array<{ id: number; callback: FrameRequestCallback }>; + let nextFrameId: number; + + function runAnimationFrame() { + const frame = rafQueue.shift(); + + act(() => { + frame?.callback(0); + }); + } + + beforeEach(() => { + rafQueue = []; + nextFrameId = 1; + + jest + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback) => { + const frame = { id: nextFrameId, callback }; + rafQueue.push(frame); + nextFrameId += 1; + return frame.id; + }); + jest.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + restoreWindowScrollProperties(); + }); + + test("threshold 기준 초기 상태를 계산한다", () => { + setWindowScroll(0); + const belowThreshold = renderHook(() => useScrollFloatingVisibility({ threshold: 100 })); + runAnimationFrame(); + + expect(belowThreshold.result.current).toEqual({ + isVisible: true, + hasPassedThreshold: false, + }); + + belowThreshold.unmount(); + + setWindowScroll(150); + const aboveThreshold = renderHook(() => useScrollFloatingVisibility({ threshold: 100 })); + runAnimationFrame(); + + expect(aboveThreshold.result.current).toEqual({ + isVisible: false, + hasPassedThreshold: true, + }); + }); + + test("기준 지점 이전에는 항상 노출된다", () => { + setWindowScroll(0); + + const { result } = renderHook(() => useScrollFloatingVisibility({ threshold: 100 })); + runAnimationFrame(); + + expect(result.current).toEqual({ + isVisible: true, + hasPassedThreshold: false, + }); + + act(() => { + setWindowScroll(90); + fireScroll(); + }); + runAnimationFrame(); + + expect(result.current).toEqual({ + isVisible: true, + hasPassedThreshold: false, + }); + }); + + test("기준 지점을 처음 지난 뒤 아래로 스크롤하면 숨기고 위로 스크롤하면 다시 노출한다", () => { + setWindowScroll(0); + + const { result } = renderHook(() => useScrollFloatingVisibility({ threshold: 100 })); + runAnimationFrame(); + + act(() => { + setWindowScroll(120); + fireScroll(); + }); + runAnimationFrame(); + + expect(result.current).toEqual({ + isVisible: false, + hasPassedThreshold: true, + }); + + act(() => { + setWindowScroll(105); + fireScroll(); + }); + runAnimationFrame(); + + expect(result.current).toEqual({ + isVisible: true, + hasPassedThreshold: true, + }); + }); + + test("delta 미만의 미세 스크롤은 노출 상태를 바꾸지 않는다", () => { + setWindowScroll(0); + + const { result } = renderHook(() => + useScrollFloatingVisibility({ + threshold: 100, + delta: 10, + }), + ); + runAnimationFrame(); + + act(() => { + setWindowScroll(140); + fireScroll(); + }); + runAnimationFrame(); + + expect(result.current).toEqual({ + isVisible: false, + hasPassedThreshold: true, + }); + + act(() => { + setWindowScroll(135); + fireScroll(); + }); + runAnimationFrame(); + + expect(result.current).toEqual({ + isVisible: false, + hasPassedThreshold: true, + }); + }); + + test("triggerRef 기준으로 hasPassedThreshold와 노출 상태를 계산한다", () => { + const trigger = document.createElement("div"); + const triggerRef = createRef(); + triggerRef.current = trigger; + + let top = 30; + jest.spyOn(trigger, "getBoundingClientRect").mockImplementation(() => createRect(top)); + + setWindowScroll(20); + const { result } = renderHook(() => + useScrollFloatingVisibility({ + offset: 0, + triggerRef, + }), + ); + runAnimationFrame(); + + expect(result.current).toEqual({ + isVisible: true, + hasPassedThreshold: false, + }); + + act(() => { + top = -5; + setWindowScroll(40); + fireScroll(); + }); + runAnimationFrame(); + + expect(result.current).toEqual({ + isVisible: false, + hasPassedThreshold: true, + }); + }); + + test("targetRef가 있으면 해당 스크롤 컨테이너 기준으로만 동작한다", () => { + const container = document.createElement("div"); + const targetRef = createRef(); + targetRef.current = container; + + setWindowScroll(0); + setElementScroll(container, 0); + + const { result } = renderHook(() => + useScrollFloatingVisibility({ + threshold: 100, + targetRef, + }), + ); + runAnimationFrame(); + + act(() => { + setWindowScroll(300); + fireScroll(window); + }); + runAnimationFrame(); + + expect(result.current).toEqual({ + isVisible: true, + hasPassedThreshold: false, + }); + + act(() => { + setElementScroll(container, 150); + fireScroll(container); + }); + runAnimationFrame(); + + expect(result.current).toEqual({ + isVisible: false, + hasPassedThreshold: true, + }); + }); + + test("resize 이벤트가 발생하면 triggerRef 위치 변화를 반영한다", () => { + const trigger = document.createElement("div"); + const triggerRef = createRef(); + triggerRef.current = trigger; + + let top = 20; + jest.spyOn(trigger, "getBoundingClientRect").mockImplementation(() => createRect(top)); + + setWindowScroll(10); + const { result } = renderHook(() => + useScrollFloatingVisibility({ + offset: 0, + triggerRef, + }), + ); + runAnimationFrame(); + + expect(result.current).toEqual({ + isVisible: true, + hasPassedThreshold: false, + }); + + act(() => { + top = -10; + window.dispatchEvent(new Event("resize")); + }); + runAnimationFrame(); + + expect(result.current).toEqual({ + isVisible: false, + hasPassedThreshold: true, + }); + }); + + test("언마운트 시 window 이벤트 리스너와 예약된 animation frame을 정리한다", () => { + setWindowScroll(0); + + const addSpy = jest.spyOn(window, "addEventListener"); + const removeSpy = jest.spyOn(window, "removeEventListener"); + + const { unmount } = renderHook(() => useScrollFloatingVisibility({ threshold: 100 })); + + expect(addSpy).toHaveBeenCalledWith("scroll", expect.any(Function), { passive: true }); + expect(addSpy).toHaveBeenCalledWith("resize", expect.any(Function)); + + unmount(); + + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(1); + expect(removeSpy).toHaveBeenCalledWith("scroll", expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function)); + }); + + test("언마운트 시 targetRef의 scroll 이벤트 리스너를 제거한다", () => { + const container = document.createElement("div"); + const targetRef = createRef(); + targetRef.current = container; + + const addSpy = jest.spyOn(container, "addEventListener"); + const removeSpy = jest.spyOn(container, "removeEventListener"); + + const { unmount } = renderHook(() => + useScrollFloatingVisibility({ + threshold: 100, + targetRef, + }), + ); + + expect(addSpy).toHaveBeenCalledWith("scroll", expect.any(Function), { passive: true }); + + unmount(); + + expect(removeSpy).toHaveBeenCalledWith("scroll", expect.any(Function)); + }); +}); diff --git a/hooks/useScrollOnNextQueryChange.test.ts b/hooks/useScrollOnNextQueryChange.test.ts new file mode 100644 index 00000000..4917ca2a --- /dev/null +++ b/hooks/useScrollOnNextQueryChange.test.ts @@ -0,0 +1,101 @@ +import { act, renderHook } from "@testing-library/react"; +import { useSearchParams } from "next/navigation"; +import useScrollOnNextQueryChange from "./useScrollOnNextQueryChange"; + +jest.mock("next/navigation", () => ({ + useSearchParams: jest.fn(), +})); + +type WritableRef = { + current: T; +}; + +function attachScrollAnchor(ref: WritableRef) { + const anchor = document.createElement("div"); + const scrollIntoView = jest.fn(); + + Object.defineProperty(anchor, "scrollIntoView", { + value: scrollIntoView, + configurable: true, + }); + + ref.current = anchor; + + return scrollIntoView; +} + +describe("useScrollOnNextQueryChange", () => { + let currentQuery = "page=1"; + + beforeEach(() => { + currentQuery = "page=1"; + jest + .mocked(useSearchParams) + .mockImplementation( + () => new URLSearchParams(currentQuery) as ReturnType, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("최초 마운트 시에는 스크롤하지 않는다", () => { + const { result } = renderHook(() => useScrollOnNextQueryChange()); + const scrollIntoView = attachScrollAnchor( + result.current.scrollAnchorRef as WritableRef, + ); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + test("markWillChange 없이 query가 바뀌면 스크롤하지 않는다", () => { + const { result, rerender } = renderHook(() => useScrollOnNextQueryChange()); + const scrollIntoView = attachScrollAnchor( + result.current.scrollAnchorRef as WritableRef, + ); + + currentQuery = "page=2"; + rerender(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + test("markWillChange 후 query가 변경되면 한 번만 지정한 behavior로 스크롤한다", () => { + const { result, rerender } = renderHook(() => + useScrollOnNextQueryChange({ + behavior: "smooth", + }), + ); + const scrollIntoView = attachScrollAnchor( + result.current.scrollAnchorRef as WritableRef, + ); + + act(() => { + result.current.markWillChange(); + }); + + currentQuery = "page=2"; + rerender(); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "smooth", + block: "start", + }); + + currentQuery = "page=3"; + rerender(); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + act(() => { + result.current.markWillChange(); + }); + + currentQuery = "page=4"; + rerender(); + + expect(scrollIntoView).toHaveBeenCalledTimes(2); + }); +});