From 72a47bc52d6d73ab47f56d39799364f4aa16db14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=ED=9D=AC?= Date: Fri, 1 May 2026 21:05:57 +0900 Subject: [PATCH 1/4] =?UTF-8?q?test(hooks):=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=9B=85=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useExpandableText, useScrollFloatingVisibility, useScrollOnNextQueryChange 테스트를 추가 Resolves: #347 --- hooks/useExpandableText.test.ts | 162 +++++++++++ hooks/useScrollFloatingVisibility.test.ts | 332 ++++++++++++++++++++++ hooks/useScrollOnNextQueryChange.test.ts | 98 +++++++ 3 files changed, 592 insertions(+) create mode 100644 hooks/useExpandableText.test.ts create mode 100644 hooks/useScrollFloatingVisibility.test.ts create mode 100644 hooks/useScrollOnNextQueryChange.test.ts diff --git a/hooks/useExpandableText.test.ts b/hooks/useExpandableText.test.ts new file mode 100644 index 00000000..28b4641e --- /dev/null +++ b/hooks/useExpandableText.test.ts @@ -0,0 +1,162 @@ +import { act, renderHook } from "@testing-library/react"; +import type { MutableRefObject } from "react"; +import { useExpandableText } from "./useExpandableText"; + +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: MutableRefObject, + heights: { scrollHeight: number; clientHeight: number }, +) { + const element = document.createElement("div"); + setElementHeights(element, heights); + ref.current = element; + return element; +} + +describe("useExpandableText", () => { + let resizeObserverCallback: ResizeObserverCallback = () => undefined; + let observe: jest.Mock; + let disconnect: jest.Mock; + + beforeEach(() => { + 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(); + }); + + test("scrollHeight가 clientHeight + 1을 초과할 때만 overflow를 true로 판단한다", () => { + const { result, rerender } = renderHook( + ({ content }) => useExpandableText({ content }), + { initialProps: { content: "first" } }, + ); + + const contentRef = result.current.contentRef as MutableRefObject; + 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 MutableRefObject; + 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 MutableRefObject; + 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 MutableRefObject; + 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..eb00d30e --- /dev/null +++ b/hooks/useScrollFloatingVisibility.test.ts @@ -0,0 +1,332 @@ +import { act, renderHook } from "@testing-library/react"; +import { createRef } from "react"; +import useScrollFloatingVisibility from "./useScrollFloatingVisibility"; + +type ScrollTarget = Window | HTMLElement; + +function setWindowScroll(value: number) { + Object.defineProperty(window, "scrollY", { + value, + configurable: true, + writable: true, + }); + Object.defineProperty(window, "pageYOffset", { + value, + configurable: true, + writable: true, + }); +} + +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(); + }); + + 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..d5c09ca4 --- /dev/null +++ b/hooks/useScrollOnNextQueryChange.test.ts @@ -0,0 +1,98 @@ +import { act, renderHook } from "@testing-library/react"; +import { useSearchParams } from "next/navigation"; +import type { MutableRefObject } from "react"; +import useScrollOnNextQueryChange from "./useScrollOnNextQueryChange"; + +jest.mock("next/navigation", () => ({ + useSearchParams: jest.fn(), +})); + +function attachScrollAnchor(ref: MutableRefObject) { + 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 MutableRefObject, + ); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + test("markWillChange 없이 query가 바뀌면 스크롤하지 않는다", () => { + const { result, rerender } = renderHook(() => useScrollOnNextQueryChange()); + const scrollIntoView = attachScrollAnchor( + result.current.scrollAnchorRef as MutableRefObject, + ); + + 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 MutableRefObject, + ); + + 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); + }); +}); From df50a5f6b3fe1627e8f1c8f169b970aa48047c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=ED=9D=AC?= Date: Fri, 1 May 2026 21:37:40 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test(useExpandableText):=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20ref=20=ED=83=80=EC=9E=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React 19에서 deprecated 된 MutableRefObject 사용을 제거하고, 테스트에서 ref.current를 직접 할당하는 의도를 명확히 하기 위해 WritableRef 타입으로 교체 Resolves: #347 --- hooks/useExpandableText.test.ts | 15 +++++++++------ hooks/useScrollOnNextQueryChange.test.ts | 13 ++++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/hooks/useExpandableText.test.ts b/hooks/useExpandableText.test.ts index 28b4641e..a4839953 100644 --- a/hooks/useExpandableText.test.ts +++ b/hooks/useExpandableText.test.ts @@ -1,7 +1,10 @@ import { act, renderHook } from "@testing-library/react"; -import type { MutableRefObject } from "react"; import { useExpandableText } from "./useExpandableText"; +type WritableRef = { + current: T; +}; + function setElementHeights( element: HTMLDivElement, { scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number }, @@ -17,7 +20,7 @@ function setElementHeights( } function attachMeasuredElement( - ref: MutableRefObject, + ref: WritableRef, heights: { scrollHeight: number; clientHeight: number }, ) { const element = document.createElement("div"); @@ -55,7 +58,7 @@ describe("useExpandableText", () => { { initialProps: { content: "first" } }, ); - const contentRef = result.current.contentRef as MutableRefObject; + const contentRef = result.current.contentRef as WritableRef; const element = attachMeasuredElement(contentRef, { scrollHeight: 101, clientHeight: 100, @@ -95,7 +98,7 @@ describe("useExpandableText", () => { { initialProps: { content: "first" } }, ); - const contentRef = result.current.contentRef as MutableRefObject; + const contentRef = result.current.contentRef as WritableRef; const element = attachMeasuredElement(contentRef, { scrollHeight: 100, clientHeight: 100, @@ -119,7 +122,7 @@ describe("useExpandableText", () => { { initialProps: { content: "first" } }, ); - const contentRef = result.current.contentRef as MutableRefObject; + const contentRef = result.current.contentRef as WritableRef; const element = attachMeasuredElement(contentRef, { scrollHeight: 100, clientHeight: 100, @@ -148,7 +151,7 @@ describe("useExpandableText", () => { { initialProps: { content: "first" } }, ); - const contentRef = result.current.contentRef as MutableRefObject; + const contentRef = result.current.contentRef as WritableRef; attachMeasuredElement(contentRef, { scrollHeight: 100, clientHeight: 100, diff --git a/hooks/useScrollOnNextQueryChange.test.ts b/hooks/useScrollOnNextQueryChange.test.ts index d5c09ca4..4917ca2a 100644 --- a/hooks/useScrollOnNextQueryChange.test.ts +++ b/hooks/useScrollOnNextQueryChange.test.ts @@ -1,13 +1,16 @@ import { act, renderHook } from "@testing-library/react"; import { useSearchParams } from "next/navigation"; -import type { MutableRefObject } from "react"; import useScrollOnNextQueryChange from "./useScrollOnNextQueryChange"; jest.mock("next/navigation", () => ({ useSearchParams: jest.fn(), })); -function attachScrollAnchor(ref: MutableRefObject) { +type WritableRef = { + current: T; +}; + +function attachScrollAnchor(ref: WritableRef) { const anchor = document.createElement("div"); const scrollIntoView = jest.fn(); @@ -40,7 +43,7 @@ describe("useScrollOnNextQueryChange", () => { test("최초 마운트 시에는 스크롤하지 않는다", () => { const { result } = renderHook(() => useScrollOnNextQueryChange()); const scrollIntoView = attachScrollAnchor( - result.current.scrollAnchorRef as MutableRefObject, + result.current.scrollAnchorRef as WritableRef, ); expect(scrollIntoView).not.toHaveBeenCalled(); @@ -49,7 +52,7 @@ describe("useScrollOnNextQueryChange", () => { test("markWillChange 없이 query가 바뀌면 스크롤하지 않는다", () => { const { result, rerender } = renderHook(() => useScrollOnNextQueryChange()); const scrollIntoView = attachScrollAnchor( - result.current.scrollAnchorRef as MutableRefObject, + result.current.scrollAnchorRef as WritableRef, ); currentQuery = "page=2"; @@ -65,7 +68,7 @@ describe("useScrollOnNextQueryChange", () => { }), ); const scrollIntoView = attachScrollAnchor( - result.current.scrollAnchorRef as MutableRefObject, + result.current.scrollAnchorRef as WritableRef, ); act(() => { From ea32eacee525128632e07cdba09ff36f42d5f59e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=ED=9D=AC?= Date: Fri, 1 May 2026 22:18:50 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test(hooks):=20resize=20observer=20?= =?UTF-8?q?=EB=AA=A8=ED=82=B9=20=EB=B3=B5=EA=B5=AC=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useExpandableText 테스트에서 전역 ResizeObserver 원본을 저장하고 복구하도록 수정 - 테스트 간 전역 객체 변경 영향이 남지 않도록 처리 Resolves: #347 --- hooks/useExpandableText.test.ts | 37 ++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/hooks/useExpandableText.test.ts b/hooks/useExpandableText.test.ts index a4839953..65b074c2 100644 --- a/hooks/useExpandableText.test.ts +++ b/hooks/useExpandableText.test.ts @@ -13,6 +13,7 @@ function setElementHeights( value: scrollHeight, configurable: true, }); + Object.defineProperty(element, "clientHeight", { value: clientHeight, configurable: true, @@ -24,22 +25,28 @@ function attachMeasuredElement( 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(), @@ -50,6 +57,12 @@ describe("useExpandableText", () => { afterEach(() => { jest.restoreAllMocks(); + + if (originalResizeObserver) { + global.ResizeObserver = originalResizeObserver; + } else { + delete (global as Partial).ResizeObserver; + } }); test("scrollHeight가 clientHeight + 1을 초과할 때만 overflow를 true로 판단한다", () => { @@ -59,15 +72,21 @@ describe("useExpandableText", () => { ); 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 }); + setElementHeights(element, { + scrollHeight: 102, + clientHeight: 100, + }); + rerender({ content: "third" }); expect(result.current.isOverflow).toBe(true); @@ -99,15 +118,20 @@ describe("useExpandableText", () => { ); 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 }); + setElementHeights(element, { + scrollHeight: 140, + clientHeight: 100, + }); act(() => { resizeObserverCallback([] as ResizeObserverEntry[], {} as ResizeObserver); @@ -123,19 +147,24 @@ describe("useExpandableText", () => { ); 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 }); + setElementHeights(element, { + scrollHeight: 140, + clientHeight: 100, + }); act(() => { resizeObserverCallback([] as ResizeObserverEntry[], {} as ResizeObserver); @@ -152,12 +181,14 @@ describe("useExpandableText", () => { ); const contentRef = result.current.contentRef as WritableRef; + attachMeasuredElement(contentRef, { scrollHeight: 100, clientHeight: 100, }); rerender({ content: "second" }); + unmount(); expect(disconnect).toHaveBeenCalled(); From 4642b642ff42ad3cd5d9ef0ff65ee1bfcf08a0ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9E=AC=ED=9D=AC?= Date: Fri, 1 May 2026 22:20:14 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test(hooks):=20window=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=86=8D=EC=84=B1=20=EB=B3=B5=EA=B5=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useScrollFloatingVisibility 테스트에서 window scrollY/pageYOffset 원본 descriptor를 저장하고 복구하도록 수정 - Object.defineProperty로 변경한 전역 스크롤 값이 테스트 간에 남지 않도록 처리 Resolves: #347 --- hooks/useScrollFloatingVisibility.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/hooks/useScrollFloatingVisibility.test.ts b/hooks/useScrollFloatingVisibility.test.ts index eb00d30e..5e3a878c 100644 --- a/hooks/useScrollFloatingVisibility.test.ts +++ b/hooks/useScrollFloatingVisibility.test.ts @@ -4,6 +4,9 @@ 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, @@ -17,6 +20,20 @@ function setWindowScroll(value: number) { }); } +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, @@ -72,6 +89,7 @@ describe("useScrollFloatingVisibility", () => { afterEach(() => { jest.restoreAllMocks(); + restoreWindowScrollProperties(); }); test("threshold 기준 초기 상태를 계산한다", () => {