All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
616 lines
20 KiB
TypeScript
616 lines
20 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
renderHook,
|
|
screen,
|
|
} from "@testing-library/react";
|
|
import { type ReactNode, useRef } from "react";
|
|
import {
|
|
MemoryRouter,
|
|
Route,
|
|
Routes,
|
|
useLocation,
|
|
useNavigate,
|
|
} from "react-router";
|
|
import { useFading } from "../../hooks/useFading.ts";
|
|
import { useFeedCache } from "../../hooks/useFeedCache.ts";
|
|
import { useTabParam } from "../../hooks/useTabParam.ts";
|
|
import {
|
|
setTitleBadge,
|
|
SITE_NAME,
|
|
useDocumentTitle,
|
|
} from "../../hooks/useDocumentTitle.ts";
|
|
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts";
|
|
import { useScrollLock } from "../../hooks/useScrollLock.ts";
|
|
import { useScrollSave } from "../../hooks/useScrollSave.ts";
|
|
import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts";
|
|
import { useSoundsEnabled } from "../../hooks/useSoundsEnabled.ts";
|
|
import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts";
|
|
import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts";
|
|
import { useHeightTransition } from "../../hooks/useHeightTransition.ts";
|
|
import { isDumpVisited } from "../../utils/visited.ts";
|
|
import { FakeIntersectionObserver } from "../setup.ts";
|
|
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
describe("useFading", () => {
|
|
beforeEach(() => vi.useFakeTimers());
|
|
|
|
test("cooldown for 2s, then dismissing for 350ms, then gone", () => {
|
|
const { result } = renderHook(useFading);
|
|
act(() => result.current.startFading("a"));
|
|
expect(result.current.fading).toEqual({ a: "cooldown" });
|
|
act(() => vi.advanceTimersByTime(2000));
|
|
expect(result.current.fading).toEqual({ a: "dismissing" });
|
|
act(() => vi.advanceTimersByTime(350));
|
|
expect(result.current.fading).toEqual({});
|
|
});
|
|
|
|
test("starting twice is one animation", () => {
|
|
const { result } = renderHook(useFading);
|
|
act(() => result.current.startFading("a"));
|
|
act(() => vi.advanceTimersByTime(1000));
|
|
act(() => result.current.startFading("a"));
|
|
act(() => vi.advanceTimersByTime(1000));
|
|
expect(result.current.fading).toEqual({ a: "dismissing" });
|
|
});
|
|
|
|
test("cancel during cooldown and during dismissal", () => {
|
|
const { result } = renderHook(useFading);
|
|
act(() => result.current.startFading("a"));
|
|
act(() => result.current.cancelFading("a"));
|
|
expect(result.current.fading).toEqual({});
|
|
act(() => vi.advanceTimersByTime(5000));
|
|
expect(result.current.fading).toEqual({});
|
|
|
|
act(() => result.current.startFading("b"));
|
|
act(() => vi.advanceTimersByTime(2100));
|
|
act(() => result.current.cancelFading("b"));
|
|
expect(result.current.fading).toEqual({});
|
|
// Cancelled means it can start over.
|
|
act(() => result.current.startFading("b"));
|
|
expect(result.current.fading).toEqual({ b: "cooldown" });
|
|
});
|
|
|
|
test("cancelAll", () => {
|
|
const { result } = renderHook(useFading);
|
|
act(() => {
|
|
result.current.startFading("a");
|
|
result.current.startFading("b");
|
|
});
|
|
act(() => result.current.cancelAll());
|
|
expect(result.current.fading).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("useFeedCache", () => {
|
|
// A MemoryRouter's first entry is a POP, as after Back.
|
|
const wrapper = () => ({ children }: { children: ReactNode }) => (
|
|
<MemoryRouter>{children}</MemoryRouter>
|
|
);
|
|
|
|
test("restores a saved feed on a back/forward (POP) visit", () => {
|
|
const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), {
|
|
wrapper: wrapper(),
|
|
});
|
|
act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300));
|
|
const second = renderHook(
|
|
() => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })),
|
|
{ wrapper: wrapper() },
|
|
);
|
|
expect(second.result.current.cached).toMatchObject({
|
|
items: [{ n: 10 }],
|
|
page: 2,
|
|
hasMore: true,
|
|
scrollY: 300,
|
|
});
|
|
});
|
|
|
|
test("an expired entry is ignored", () => {
|
|
vi.useFakeTimers();
|
|
const first = renderHook(() => useFeedCache("k", (x) => x), {
|
|
wrapper: wrapper(),
|
|
});
|
|
act(() => first.result.current.saveState([], 1, false, 0));
|
|
vi.advanceTimersByTime(11 * 60 * 1000);
|
|
const second = renderHook(() => useFeedCache("k", (x) => x), {
|
|
wrapper: wrapper(),
|
|
});
|
|
expect(second.result.current.cached).toBeNull();
|
|
});
|
|
|
|
test("a fresh (PUSH) visit ignores the cache", () => {
|
|
sessionStorage.setItem(
|
|
"k",
|
|
JSON.stringify({
|
|
items: [1],
|
|
page: 1,
|
|
hasMore: false,
|
|
scrollY: 0,
|
|
savedAt: Date.now(),
|
|
}),
|
|
);
|
|
function Probe() {
|
|
const { cached } = useFeedCache("k", (x) => x);
|
|
return <p>{cached ? "cached" : "fresh"}</p>;
|
|
}
|
|
function Home() {
|
|
const navigate = useNavigate();
|
|
return (
|
|
<button type="button" onClick={() => navigate("/feed")}>
|
|
go
|
|
</button>
|
|
);
|
|
}
|
|
render(
|
|
<MemoryRouter>
|
|
<Routes>
|
|
<Route path="/" element={<Home />} />
|
|
<Route path="/feed" element={<Probe />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
fireEvent.click(screen.getByText("go"));
|
|
expect(screen.getByText("fresh")).toBeInTheDocument();
|
|
});
|
|
|
|
test("corrupt storage reads as no cache; a full quota doesn't throw", () => {
|
|
sessionStorage.setItem("k", "{");
|
|
const { result } = renderHook(() => useFeedCache("k", (x) => x), {
|
|
wrapper: wrapper(),
|
|
});
|
|
expect(result.current.cached).toBeNull();
|
|
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
|
throw new Error("QuotaExceededError");
|
|
});
|
|
expect(() => result.current.saveState([], 1, false, 0)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("useTabParam", () => {
|
|
const TABS = ["dumps", "playlists", "votes"] as const;
|
|
function setup(path: string, state?: unknown) {
|
|
let loc!: ReturnType<typeof useLocation>;
|
|
const hook = renderHook(() => {
|
|
loc = useLocation();
|
|
return useTabParam(TABS, "dumps");
|
|
}, {
|
|
wrapper: ({ children }) => (
|
|
<MemoryRouter
|
|
initialEntries={[{
|
|
pathname: path.split("?")[0],
|
|
search: path.includes("?") ? "?" + path.split("?")[1] : "",
|
|
state,
|
|
}]}
|
|
>
|
|
{children}
|
|
</MemoryRouter>
|
|
),
|
|
});
|
|
return { hook, loc: () => loc };
|
|
}
|
|
|
|
test("reads the tab after /~/", () => {
|
|
expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe(
|
|
"playlists",
|
|
);
|
|
expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe(
|
|
"playlists",
|
|
);
|
|
expect(setup("/users/ann").hook.result.current[0]).toBe("dumps");
|
|
expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps");
|
|
});
|
|
|
|
test("the default tab collapses to the base path", () => {
|
|
const { hook, loc } = setup("/users/ann/~/votes");
|
|
act(() => hook.result.current[1]("dumps"));
|
|
expect(loc().pathname).toBe("/users/ann");
|
|
act(() => hook.result.current[1]("playlists"));
|
|
expect(loc().pathname).toBe("/users/ann/~/playlists");
|
|
});
|
|
|
|
test("at the root the default stays explicit", () => {
|
|
const { hook, loc } = setup("/~/votes");
|
|
act(() => hook.result.current[1]("dumps"));
|
|
expect(loc().pathname).toBe("/~/dumps");
|
|
});
|
|
|
|
test("keeps the query string and the entry's state", () => {
|
|
const { hook, loc } = setup("/search?q=cats", { back: "/x" });
|
|
act(() => hook.result.current[1]("votes"));
|
|
expect(loc().pathname).toBe("/search/~/votes");
|
|
expect(loc().search).toBe("?q=cats");
|
|
expect(loc().state).toEqual({ back: "/x" });
|
|
});
|
|
});
|
|
|
|
describe("useDocumentTitle", () => {
|
|
test("composes page, site name and unread badge", () => {
|
|
const { rerender } = renderHook(
|
|
({ title }: { title: string | null | undefined }) =>
|
|
useDocumentTitle(title),
|
|
{ initialProps: { title: "Cats" as string | null | undefined } },
|
|
);
|
|
expect(document.title).toBe(`Cats · ${SITE_NAME}`);
|
|
act(() => setTitleBadge(3));
|
|
expect(document.title).toBe(`(3) Cats · ${SITE_NAME}`);
|
|
act(() => setTitleBadge(150));
|
|
expect(document.title).toBe(`(99+) Cats · ${SITE_NAME}`);
|
|
rerender({ title: null });
|
|
expect(document.title).toBe(`(99+) ${SITE_NAME}`);
|
|
act(() => setTitleBadge(0));
|
|
expect(document.title).toBe(SITE_NAME);
|
|
// undefined leaves it be
|
|
rerender({ title: undefined });
|
|
expect(document.title).toBe(SITE_NAME);
|
|
});
|
|
|
|
test("the site name defaults to gerbeur when unsubstituted", () => {
|
|
expect(SITE_NAME).toBe("gerbeur");
|
|
});
|
|
});
|
|
|
|
describe("useInfiniteScroll", () => {
|
|
function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) {
|
|
const ref = useInfiniteScroll(onMore, enabled);
|
|
return <div ref={ref} data-testid="sentinel" />;
|
|
}
|
|
const intersect = (isIntersecting: boolean) => {
|
|
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
|
act(() => obs.report(isIntersecting));
|
|
};
|
|
|
|
test("loads more when the sentinel shows, only while enabled", () => {
|
|
const onMore = vi.fn();
|
|
const { rerender } = render(<Feed onMore={onMore} enabled />);
|
|
expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1);
|
|
intersect(false);
|
|
expect(onMore).not.toHaveBeenCalled();
|
|
intersect(true);
|
|
expect(onMore).toHaveBeenCalledTimes(1);
|
|
rerender(<Feed onMore={onMore} enabled={false} />);
|
|
intersect(true);
|
|
expect(onMore).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// A load that fails re-enables the feed with the sentinel still in view: a
|
|
// browser's new observer reports it at once, and without a pause that's a
|
|
// request loop as fast as the request can fail.
|
|
test("a sentinel that stays in view waits longer each time", () => {
|
|
vi.useFakeTimers();
|
|
const onMore = vi.fn();
|
|
render(<Feed onMore={onMore} enabled />);
|
|
intersect(true);
|
|
expect(onMore).toHaveBeenCalledTimes(1);
|
|
// Reported again right after each load, as a new observer does: how long
|
|
// until the next one?
|
|
const delays: number[] = [];
|
|
for (let i = 0; i < 8; i++) {
|
|
const before = onMore.mock.calls.length;
|
|
intersect(true);
|
|
let waited = 0;
|
|
while (onMore.mock.calls.length === before) {
|
|
act(() => vi.advanceTimersByTime(100));
|
|
waited += 100;
|
|
}
|
|
delays.push(waited);
|
|
}
|
|
expect(delays).toEqual([
|
|
1000,
|
|
2000,
|
|
4000,
|
|
8000,
|
|
16000,
|
|
30000,
|
|
30000,
|
|
30000,
|
|
]);
|
|
});
|
|
|
|
test("re-rendering doesn't restart the wait", () => {
|
|
vi.useFakeTimers();
|
|
const onMore = vi.fn();
|
|
const { rerender } = render(<Feed onMore={onMore} enabled />);
|
|
intersect(true);
|
|
intersect(true);
|
|
act(() => vi.advanceTimersByTime(600));
|
|
// A new onLoadMore (the feed's state changed) remakes the observer.
|
|
rerender(<Feed onMore={() => onMore()} enabled />);
|
|
intersect(true);
|
|
act(() => vi.advanceTimersByTime(400));
|
|
expect(onMore).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test("leaving the view, or new items pushing it down, asks at once again", () => {
|
|
vi.useFakeTimers();
|
|
const onMore = vi.fn();
|
|
render(<Feed onMore={onMore} enabled />);
|
|
const at = (top: number) => {
|
|
const obs = FakeIntersectionObserver.instances.at(-1)!;
|
|
act(() => obs.report(true, top));
|
|
};
|
|
at(500);
|
|
at(500);
|
|
expect(onMore).toHaveBeenCalledTimes(1); // waiting
|
|
act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500));
|
|
at(500);
|
|
expect(onMore).toHaveBeenCalledTimes(2);
|
|
at(500);
|
|
at(900); // the page grew: that load worked
|
|
expect(onMore).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
test("a pending retry is dropped when the feed is disabled or unmounted", () => {
|
|
vi.useFakeTimers();
|
|
const onMore = vi.fn();
|
|
const { rerender, unmount } = render(<Feed onMore={onMore} enabled />);
|
|
intersect(true);
|
|
intersect(true);
|
|
rerender(<Feed onMore={onMore} enabled={false} />);
|
|
act(() => vi.advanceTimersByTime(60_000));
|
|
expect(onMore).toHaveBeenCalledTimes(1);
|
|
rerender(<Feed onMore={onMore} enabled />);
|
|
intersect(true); // long overdue: at once
|
|
expect(onMore).toHaveBeenCalledTimes(2);
|
|
intersect(true);
|
|
unmount();
|
|
act(() => vi.advanceTimersByTime(60_000));
|
|
expect(onMore).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("useScrollLock", () => {
|
|
function Lock() {
|
|
useScrollLock();
|
|
return null;
|
|
}
|
|
test("counts nested locks", () => {
|
|
const root = document.documentElement;
|
|
const a = render(<Lock />);
|
|
const b = render(<Lock />);
|
|
expect(root.style.overflowY).toBe("hidden");
|
|
a.unmount();
|
|
expect(root.style.overflowY).toBe("hidden");
|
|
b.unmount();
|
|
expect(root.style.overflowY).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("useScrollSave", () => {
|
|
const scrollTo = (y: number) => {
|
|
Object.defineProperty(globalThis, "scrollY", {
|
|
value: y,
|
|
configurable: true,
|
|
});
|
|
globalThis.dispatchEvent(new Event("scroll"));
|
|
};
|
|
|
|
test("saves the last position when the page is left, not while scrolling", () => {
|
|
const onSave = vi.fn();
|
|
const { unmount } = renderHook(() => useScrollSave(true, onSave));
|
|
scrollTo(100);
|
|
scrollTo(250);
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
// The next page has already scrolled back up by the time this unmounts.
|
|
Object.defineProperty(globalThis, "scrollY", {
|
|
value: 0,
|
|
configurable: true,
|
|
});
|
|
unmount();
|
|
expect(onSave).toHaveBeenCalledExactlyOnceWith(250);
|
|
});
|
|
|
|
test("saves on pagehide, with the latest callback", () => {
|
|
const first = vi.fn();
|
|
const second = vi.fn();
|
|
const { rerender, unmount } = renderHook(
|
|
({ cb }) => useScrollSave(true, cb),
|
|
{ initialProps: { cb: first } },
|
|
);
|
|
scrollTo(40);
|
|
rerender({ cb: second });
|
|
globalThis.dispatchEvent(new Event("pagehide"));
|
|
expect(first).not.toHaveBeenCalled();
|
|
expect(second).toHaveBeenCalledExactlyOnceWith(40);
|
|
unmount();
|
|
});
|
|
|
|
test("nothing is tracked or saved while disabled", () => {
|
|
const onSave = vi.fn();
|
|
const { unmount } = renderHook(() => useScrollSave(false, onSave));
|
|
scrollTo(10);
|
|
globalThis.dispatchEvent(new Event("pagehide"));
|
|
unmount();
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("stored preferences", () => {
|
|
test("useDefaultFeedTab validates and persists", () => {
|
|
localStorage.setItem("default-feed-tab", "bogus");
|
|
const { result } = renderHook(useDefaultFeedTab);
|
|
expect(result.current[0]).toBe("journal");
|
|
act(() => result.current[1]("new"));
|
|
expect(result.current[0]).toBe("new");
|
|
expect(localStorage.getItem("default-feed-tab")).toBe("new");
|
|
expect(renderHook(useDefaultFeedTab).result.current[0]).toBe("new");
|
|
});
|
|
|
|
test("useSoundsEnabled", () => {
|
|
const { result } = renderHook(useSoundsEnabled);
|
|
expect(result.current[0]).toBe(true);
|
|
act(() => result.current[1](false));
|
|
expect(result.current[0]).toBe(false);
|
|
expect(localStorage.getItem("sounds")).toBe("off");
|
|
});
|
|
});
|
|
|
|
describe("useDumpCardLink", () => {
|
|
function Card() {
|
|
const link = useDumpCardLink({ id: "d1", slug: "hello" });
|
|
return (
|
|
<div data-testid="card" {...link.cardProps}>
|
|
<a href="/elsewhere">inner link</a>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
if (!link.handledAsNewTab(e)) e.currentTarget.dataset.played = "1";
|
|
}}
|
|
>
|
|
play
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
function setup() {
|
|
const open = vi.spyOn(globalThis, "open").mockImplementation(() => null);
|
|
render(
|
|
<MemoryRouter>
|
|
<Routes>
|
|
<Route path="/" element={<Card />} />
|
|
<Route path="/dumps/:id" element={<p>dump page</p>} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
return { open };
|
|
}
|
|
|
|
test("a click navigates and marks visited", () => {
|
|
setup();
|
|
fireEvent.click(screen.getByTestId("card"));
|
|
expect(screen.getByText("dump page")).toBeInTheDocument();
|
|
expect(isDumpVisited("d1")).toBe(true);
|
|
});
|
|
|
|
test("ctrl/cmd/shift-click opens a new tab", () => {
|
|
const { open } = setup();
|
|
fireEvent.click(screen.getByTestId("card"), { ctrlKey: true });
|
|
expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener");
|
|
});
|
|
|
|
test("a middle click opens a new tab, except over a link", () => {
|
|
const { open } = setup();
|
|
const mouseDown = new MouseEvent("mousedown", {
|
|
button: 1,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
screen.getByTestId("card").dispatchEvent(mouseDown);
|
|
expect(mouseDown.defaultPrevented).toBe(true);
|
|
fireEvent(
|
|
screen.getByTestId("card"),
|
|
new MouseEvent("auxclick", { button: 1, bubbles: true }),
|
|
);
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
fireEvent(
|
|
screen.getByText("inner link"),
|
|
new MouseEvent("auxclick", { button: 1, bubbles: true }),
|
|
);
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
fireEvent(
|
|
screen.getByTestId("card"),
|
|
new MouseEvent("auxclick", { button: 2, bubbles: true }),
|
|
);
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("a modified click on the play button opens the dump instead of playing", () => {
|
|
const { open } = setup();
|
|
const play = screen.getByText("play");
|
|
fireEvent.click(play, { metaKey: true });
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
expect(play.dataset.played).toBeUndefined();
|
|
fireEvent.click(play);
|
|
expect(play.dataset.played).toBe("1");
|
|
});
|
|
});
|
|
|
|
describe("useAnchoredFloat", () => {
|
|
function Float({ open, top }: { open: boolean; top: number }) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const style = useAnchoredFloat(ref, open, {
|
|
width: 300,
|
|
chromeHeight: 40,
|
|
minContentHeight: 100,
|
|
maxContentHeight: 400,
|
|
});
|
|
return (
|
|
<>
|
|
<div
|
|
ref={(el) => {
|
|
ref.current = el;
|
|
if (el) {
|
|
el.getBoundingClientRect = () =>
|
|
({
|
|
top,
|
|
bottom: top + 20,
|
|
left: 900,
|
|
right: 1000,
|
|
width: 100,
|
|
height: 20,
|
|
}) as DOMRect;
|
|
}
|
|
}}
|
|
/>
|
|
<output data-testid="style">{JSON.stringify(style)}</output>
|
|
</>
|
|
);
|
|
}
|
|
const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!);
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("innerWidth", 1024);
|
|
vi.stubGlobal("innerHeight", 768);
|
|
});
|
|
|
|
test("null while closed", () => {
|
|
render(<Float open={false} top={100} />);
|
|
expect(styleOf()).toBeNull();
|
|
});
|
|
|
|
test("opens below an anchor near the top, clamped into the viewport", () => {
|
|
render(<Float open top={100} />);
|
|
const s = styleOf();
|
|
expect(s.position).toBe("fixed");
|
|
expect(s.top).toBe(124);
|
|
expect(s.bottom).toBe("auto");
|
|
expect(s.left).toBe(1024 - 300 - 8);
|
|
expect(s["--float-max-h"]).toBe("400px");
|
|
});
|
|
|
|
test("flips above an anchor near the bottom", () => {
|
|
render(<Float open top={700} />);
|
|
const s = styleOf();
|
|
expect(s.bottom).toBe(768 - 700 + 4);
|
|
expect(s.top).toBe("auto");
|
|
});
|
|
});
|
|
|
|
describe("useHeightTransition", () => {
|
|
test("animates from the old height to the new one when watch changes", () => {
|
|
const animate = vi.spyOn(Element.prototype, "animate");
|
|
let height = 100;
|
|
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
|
|
() => ({ height }) as DOMRect,
|
|
);
|
|
function Box({ open }: { open: boolean }) {
|
|
const ref = useHeightTransition<HTMLDivElement>(open);
|
|
return <div ref={ref} />;
|
|
}
|
|
const { rerender } = render(<Box open={false} />);
|
|
height = 250;
|
|
rerender(<Box open />);
|
|
expect(animate).toHaveBeenCalledTimes(1);
|
|
expect(animate.mock.calls[0][0]).toEqual([
|
|
expect.objectContaining({ height: "100px" }),
|
|
expect.objectContaining({ height: "250px" }),
|
|
]);
|
|
|
|
// No change in height, no animation.
|
|
rerender(<Box open={false} />);
|
|
expect(animate).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|