This commit is contained in:
390
frontend/__tests__/components/common/anime/Anime.spec.tsx
Normal file
390
frontend/__tests__/components/common/anime/Anime.spec.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { cleanup, render } from "@solidjs/testing-library";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAnimate } = vi.hoisted(() => ({
|
||||
mockAnimate: vi.fn().mockReturnValue({
|
||||
pause: vi.fn(),
|
||||
then: vi.fn((_cb: unknown) => Promise.resolve()),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("animejs", () => ({
|
||||
animate: mockAnimate,
|
||||
}));
|
||||
|
||||
// Mock applyReducedMotion
|
||||
vi.mock("../../../../src/ts/utils/misc", () => ({
|
||||
applyReducedMotion: vi.fn((duration: number) => duration),
|
||||
}));
|
||||
|
||||
import { Anime } from "../../../../src/ts/components/common/anime/Anime";
|
||||
import {
|
||||
AnimeGroup,
|
||||
createStagger,
|
||||
} from "../../../../src/ts/components/common/anime/AnimeGroup";
|
||||
import { AnimePresence } from "../../../../src/ts/components/common/anime/AnimePresence";
|
||||
|
||||
describe("Anime", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a div wrapper by default", () => {
|
||||
const { container } = render(() => (
|
||||
<Anime animation={{ opacity: 1 }}>
|
||||
<span>content</span>
|
||||
</Anime>
|
||||
));
|
||||
expect(container.querySelector("div")).toBeTruthy();
|
||||
expect(container.querySelector("span")).toHaveTextContent("content");
|
||||
});
|
||||
|
||||
it("renders with custom tag via `as` prop", () => {
|
||||
const { container } = render(() => (
|
||||
<Anime animation={{ opacity: 1 }} as="section">
|
||||
<span>hi</span>
|
||||
</Anime>
|
||||
));
|
||||
expect(container.querySelector("section")).toBeTruthy();
|
||||
expect(container.querySelector("div")).toBeNull();
|
||||
});
|
||||
|
||||
it("applies className and style props", () => {
|
||||
const { container } = render(() => (
|
||||
<Anime
|
||||
animation={{ opacity: 1 }}
|
||||
class="my-class"
|
||||
style={{ color: "red" }}
|
||||
>
|
||||
<span />
|
||||
</Anime>
|
||||
));
|
||||
const el = container.querySelector(".my-class");
|
||||
expect(el).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls animejsAnimate on mount with animation prop", () => {
|
||||
render(() => (
|
||||
<Anime animation={{ opacity: 1, duration: 300 }}>
|
||||
<div />
|
||||
</Anime>
|
||||
));
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 300 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies initial state with duration:0 then animates to animate prop", () => {
|
||||
render(() => (
|
||||
<Anime initial={{ opacity: 0 }} animate={{ opacity: 1, duration: 300 }}>
|
||||
<div />
|
||||
</Anime>
|
||||
));
|
||||
|
||||
// First call: initial state (duration: 0)
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 0, duration: 0 }),
|
||||
);
|
||||
// Second call: full animation
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 300 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-runs animation when reactive signal changes", () => {
|
||||
const [opacity, setOpacity] = createSignal(1);
|
||||
|
||||
render(() => (
|
||||
<Anime animation={{ opacity: opacity(), duration: 200 }}>
|
||||
<div />
|
||||
</Anime>
|
||||
));
|
||||
|
||||
const callsBefore = mockAnimate.mock.calls.length;
|
||||
setOpacity(0);
|
||||
|
||||
expect(mockAnimate.mock.calls.length).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AnimePresence", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders children", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimePresence>
|
||||
<div data-testid="child">hello</div>
|
||||
</AnimePresence>
|
||||
));
|
||||
expect(container.querySelector("[data-testid='child']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders children in list mode", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimePresence mode="list">
|
||||
<div data-testid="item-1">one</div>
|
||||
<div data-testid="item-2">two</div>
|
||||
</AnimePresence>
|
||||
));
|
||||
expect(container.querySelector("[data-testid='item-1']")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='item-2']")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("list mode wraps children in a display:contents div", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimePresence mode="list">
|
||||
<div>child</div>
|
||||
</AnimePresence>
|
||||
));
|
||||
const wrapper = container.querySelector("div");
|
||||
expect(wrapper?.style.display).toBe("contents");
|
||||
});
|
||||
|
||||
it("mounts and unmounts Show child without errors", async () => {
|
||||
const [show, setShow] = createSignal(true);
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimePresence>
|
||||
<Show when={show()}>
|
||||
<Anime animate={{ opacity: 1, duration: 0 }}>
|
||||
<div data-testid="toggled">toggled</div>
|
||||
</Anime>
|
||||
</Show>
|
||||
</AnimePresence>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setShow(false)).not.toThrow();
|
||||
});
|
||||
|
||||
it("exitBeforeEnter mode does not throw on child switch", () => {
|
||||
const [view, setView] = createSignal<"a" | "b">("a");
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimePresence exitBeforeEnter>
|
||||
<Show when={view() === "a"}>
|
||||
<Anime exit={{ opacity: 0, duration: 0 }}>
|
||||
<div>View A</div>
|
||||
</Anime>
|
||||
</Show>
|
||||
<Show when={view() === "b"}>
|
||||
<Anime exit={{ opacity: 0, duration: 0 }}>
|
||||
<div>View B</div>
|
||||
</Anime>
|
||||
</Show>
|
||||
</AnimePresence>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setView("b")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AnimeGroup", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a div wrapper by default", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1, duration: 300 }}>
|
||||
<div>a</div>
|
||||
<div>b</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
expect(container.querySelector("div")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders with custom tag via `as` prop", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1 }} as="ul">
|
||||
<li>item</li>
|
||||
</AnimeGroup>
|
||||
));
|
||||
expect(container.querySelector("ul")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("animates each child on mount", () => {
|
||||
render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1, duration: 300 }} stagger={50}>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
// One call per child element
|
||||
const childCalls = mockAnimate.mock.calls.filter(
|
||||
([el]) => el instanceof HTMLElement,
|
||||
);
|
||||
expect(childCalls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("applies initial state before animating children", () => {
|
||||
render(() => (
|
||||
<AnimeGroup
|
||||
animation={{ opacity: 1, duration: 300 }}
|
||||
initial={{ opacity: 0 }}
|
||||
>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
// Initial state calls (duration: 0) should precede animation calls
|
||||
const zeroDurationCalls = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.duration === 0,
|
||||
);
|
||||
expect(zeroDurationCalls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("applies stagger delays in forward direction", () => {
|
||||
render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1, duration: 300 }} stagger={100}>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
// Calls with non-zero delay values reflecting stagger
|
||||
const delayCalls = mockAnimate.mock.calls
|
||||
.filter(([, params]) => params.duration === 300)
|
||||
.map(([, params]) => params.delay as number);
|
||||
|
||||
// forward stagger: delays should be 0, 100, 200
|
||||
expect(delayCalls).toContain(0);
|
||||
expect(delayCalls).toContain(100);
|
||||
expect(delayCalls).toContain(200);
|
||||
});
|
||||
|
||||
it("reverses stagger direction", () => {
|
||||
render(() => (
|
||||
<AnimeGroup
|
||||
animation={{ opacity: 1, duration: 300 }}
|
||||
stagger={100}
|
||||
direction="reverse"
|
||||
>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
const delayCalls = mockAnimate.mock.calls
|
||||
.filter(([, params]) => params.duration === 300)
|
||||
.map(([, params]) => params.delay as number);
|
||||
|
||||
// reverse: first child gets highest delay (200), last gets 0
|
||||
expect(delayCalls).toContain(0);
|
||||
expect(delayCalls).toContain(200);
|
||||
});
|
||||
|
||||
it("applies center stagger direction", () => {
|
||||
render(() => (
|
||||
<AnimeGroup
|
||||
animation={{ opacity: 1, duration: 300 }}
|
||||
stagger={100}
|
||||
direction="center"
|
||||
>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
<div>3</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
const delayCalls = mockAnimate.mock.calls
|
||||
.filter(([, params]) => params.duration === 300)
|
||||
.map(([, params]) => params.delay as number);
|
||||
|
||||
// center: middle element (index 1) has 0 delay, outer elements have 100
|
||||
expect(delayCalls).toContain(0);
|
||||
expect(delayCalls).toContain(100);
|
||||
});
|
||||
|
||||
it("accepts a function stagger", () => {
|
||||
const staggerFn = vi.fn((_i: number, _t: number) => 75);
|
||||
|
||||
render(() => (
|
||||
<AnimeGroup animation={{ opacity: 1, duration: 300 }} stagger={staggerFn}>
|
||||
<div>1</div>
|
||||
<div>2</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
|
||||
expect(staggerFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies class and style to wrapper", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeGroup
|
||||
animation={{ opacity: 1 }}
|
||||
class="group-class"
|
||||
style={{ gap: "8px" }}
|
||||
>
|
||||
<div>1</div>
|
||||
</AnimeGroup>
|
||||
));
|
||||
expect(container.querySelector(".group-class")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStagger", () => {
|
||||
it("returns 0 for single element", () => {
|
||||
const fn = createStagger({ base: 100 });
|
||||
expect(fn(0, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it("linear stagger from start: first=0, last=base*(total-1)", () => {
|
||||
const fn = createStagger({ base: 50, ease: "linear", from: "start" });
|
||||
expect(fn(0, 3)).toBeCloseTo(0);
|
||||
expect(fn(2, 3)).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it("linear stagger from end: first=base*(total-1), last=0", () => {
|
||||
const fn = createStagger({ base: 50, ease: "linear", from: "end" });
|
||||
expect(fn(0, 3)).toBeCloseTo(100);
|
||||
expect(fn(2, 3)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("center stagger: middle element has smallest value", () => {
|
||||
const fn = createStagger({ base: 50, ease: "linear", from: "center" });
|
||||
// For 5 items, center is index 2 → distance = 0
|
||||
expect(fn(2, 5)).toBeCloseTo(0);
|
||||
expect(fn(0, 5)).toBeGreaterThan(fn(2, 5));
|
||||
});
|
||||
|
||||
it("easeIn produces smaller values at start", () => {
|
||||
const linear = createStagger({ base: 100, ease: "linear" });
|
||||
const easeIn = createStagger({ base: 100, ease: "easeIn" });
|
||||
// At index 1 of 4, easeIn position is less progressed than linear
|
||||
expect(easeIn(1, 4)).toBeLessThan(linear(1, 4));
|
||||
});
|
||||
|
||||
it("easeOut produces larger values at start compared to easeIn", () => {
|
||||
const easeOut = createStagger({ base: 100, ease: "easeOut" });
|
||||
const easeIn = createStagger({ base: 100, ease: "easeIn" });
|
||||
expect(easeOut(1, 4)).toBeGreaterThan(easeIn(1, 4));
|
||||
});
|
||||
|
||||
it("easeInOut is symmetric", () => {
|
||||
const fn = createStagger({ base: 100, ease: "easeInOut", from: "start" });
|
||||
// At 50% position (index 1 of 3), easeInOut should equal linear
|
||||
// easeInOut at 0.5 = 0.5 → 100 * 0.5 * 2 = 100
|
||||
expect(fn(1, 3)).toBeCloseTo(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { cleanup, render, screen } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAnimate } = vi.hoisted(() => ({
|
||||
mockAnimate: vi.fn().mockImplementation(() => ({
|
||||
pause: vi.fn(),
|
||||
then: vi.fn((cb: () => void) => {
|
||||
cb();
|
||||
return Promise.resolve();
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("animejs", () => ({
|
||||
animate: mockAnimate,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/ts/utils/misc", () => ({
|
||||
applyReducedMotion: vi.fn((duration: number) => duration),
|
||||
}));
|
||||
|
||||
import { AnimeConditional } from "../../../../src/ts/components/common/anime/AnimeConditional";
|
||||
|
||||
describe("AnimeConditional", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders `then` content when `if` is truthy", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={true}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("then-content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("else-content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders `else` content when `if` is falsy", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={false}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByTestId("then-content")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("else-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders `else` content when `if` is null", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={null}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.queryByTestId("then-content")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("else-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches reactively from `then` to `else`", () => {
|
||||
const [condition, setCondition] = createSignal(true);
|
||||
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={condition()}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("then-content")).toBeInTheDocument();
|
||||
|
||||
setCondition(false);
|
||||
|
||||
expect(screen.queryByTestId("then-content")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("else-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches reactively from `else` to `then`", () => {
|
||||
const [condition, setCondition] = createSignal(false);
|
||||
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={condition()}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
else={<div data-testid="else-content">else</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("else-content")).toBeInTheDocument();
|
||||
|
||||
setCondition(true);
|
||||
|
||||
expect(screen.getByTestId("then-content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("else-content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports `then` as a function and passes the truthy value", () => {
|
||||
const obj = { label: "hello" };
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={obj}
|
||||
then={(value) => <div data-testid="fn-content">{value().label}</div>}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("fn-content")).toHaveTextContent("hello");
|
||||
});
|
||||
|
||||
it("does not throw without `else` prop", () => {
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={true}
|
||||
then={<div data-testid="then-content">then</div>}
|
||||
/>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(screen.getByTestId("then-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not throw on mount/unmount", () => {
|
||||
const [show, setShow] = createSignal(true);
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={show()}
|
||||
then={<div>then</div>}
|
||||
else={<div>else</div>}
|
||||
/>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setShow(false)).not.toThrow();
|
||||
expect(() => setShow(true)).not.toThrow();
|
||||
});
|
||||
|
||||
describe("default animations (opacity fade)", () => {
|
||||
it("applies default opacity animate on `then` branch", () => {
|
||||
render(() => <AnimeConditional if={true} then={<div>then</div>} />);
|
||||
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 125 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies default opacity initial state on `then` branch", () => {
|
||||
render(() => <AnimeConditional if={true} then={<div>then</div>} />);
|
||||
|
||||
// Initial call: opacity:0 with duration:0
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 0, duration: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom animeProps", () => {
|
||||
it("uses custom animate params when animeProps provided", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={true}
|
||||
then={<div>then</div>}
|
||||
animeProps={{
|
||||
initial: { opacity: 0, translateY: -10 },
|
||||
animate: { opacity: 1, translateY: 0, duration: 400 },
|
||||
exit: { opacity: 0, translateY: -10, duration: 200 },
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, translateY: 0, duration: 400 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses custom initial state when animeProps provided", () => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
if={true}
|
||||
then={<div>then</div>}
|
||||
animeProps={{
|
||||
initial: { opacity: 0, translateY: -10 },
|
||||
animate: { opacity: 1, translateY: 0, duration: 400 },
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
// Initial state applied with duration:0
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 0, translateY: -10, duration: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("exitBeforeEnter prop does not throw on condition change", () => {
|
||||
const [cond, setCond] = createSignal(true);
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeConditional
|
||||
exitBeforeEnter
|
||||
if={cond()}
|
||||
then={<div>then</div>}
|
||||
else={<div>else</div>}
|
||||
/>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setCond(false)).not.toThrow();
|
||||
});
|
||||
});
|
||||
157
frontend/__tests__/components/common/anime/AnimeShow.spec.tsx
Normal file
157
frontend/__tests__/components/common/anime/AnimeShow.spec.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { cleanup, render, screen } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAnimate } = vi.hoisted(() => ({
|
||||
mockAnimate: vi.fn().mockImplementation(() => {
|
||||
const callbacks: Array<() => void> = [];
|
||||
const animation = {
|
||||
pause: vi.fn(),
|
||||
then: vi.fn((cb: () => void) => {
|
||||
callbacks.push(cb);
|
||||
// Invoke immediately so exit animations complete synchronously in tests
|
||||
cb();
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
return animation;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("animejs", () => ({
|
||||
animate: mockAnimate,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/ts/utils/misc", () => ({
|
||||
applyReducedMotion: vi.fn((duration: number) => duration),
|
||||
}));
|
||||
|
||||
import { AnimeShow } from "../../../../src/ts/components/common/anime/AnimeShow";
|
||||
|
||||
describe("AnimeShow", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders children when `when` is true", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true}>
|
||||
<div data-testid="content">hello</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(screen.getByTestId("content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render children when `when` is false", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={false}>
|
||||
<div data-testid="content">hello</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(screen.queryByTestId("content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows and hides reactively", () => {
|
||||
const [visible, setVisible] = createSignal(true);
|
||||
|
||||
render(() => (
|
||||
<AnimeShow when={visible()}>
|
||||
<div data-testid="content">hello</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("content")).toBeInTheDocument();
|
||||
setVisible(false);
|
||||
expect(screen.queryByTestId("content")).not.toBeInTheDocument();
|
||||
setVisible(true);
|
||||
expect(screen.getByTestId("content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies class to the wrapper element when visible", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeShow when={true} class="my-class">
|
||||
<span>content</span>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(container.querySelector(".my-class")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not throw on mount/unmount", () => {
|
||||
const [show, setShow] = createSignal(true);
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeShow when={show()}>
|
||||
<div>content</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setShow(false)).not.toThrow();
|
||||
});
|
||||
|
||||
describe("slide mode", () => {
|
||||
it("renders children when `when` is true in slide mode", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true} slide>
|
||||
<div data-testid="slide-content">slide</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(screen.getByTestId("slide-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render children when `when` is false in slide mode", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={false} slide>
|
||||
<div data-testid="slide-content">slide</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
expect(screen.queryByTestId("slide-content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("animates height in slide mode", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true} slide>
|
||||
<div>content</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
|
||||
const heightCalls = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.height !== undefined,
|
||||
);
|
||||
expect(heightCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duration prop", () => {
|
||||
it("uses the provided duration", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true} duration={400}>
|
||||
<div>content</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
|
||||
const durationCalls = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.duration === 400,
|
||||
);
|
||||
expect(durationCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("defaults to 125ms when no duration is provided", () => {
|
||||
render(() => (
|
||||
<AnimeShow when={true}>
|
||||
<div>content</div>
|
||||
</AnimeShow>
|
||||
));
|
||||
|
||||
const defaultDurationCalls = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.duration === 125,
|
||||
);
|
||||
expect(defaultDurationCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
205
frontend/__tests__/components/common/anime/AnimeSwitch.spec.tsx
Normal file
205
frontend/__tests__/components/common/anime/AnimeSwitch.spec.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { cleanup, render, screen } from "@solidjs/testing-library";
|
||||
import { createSignal } from "solid-js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockAnimate } = vi.hoisted(() => ({
|
||||
mockAnimate: vi.fn().mockImplementation(() => ({
|
||||
pause: vi.fn(),
|
||||
then: vi.fn((cb: () => void) => {
|
||||
cb();
|
||||
return Promise.resolve();
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("animejs", () => ({
|
||||
animate: mockAnimate,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/ts/utils/misc", () => ({
|
||||
applyReducedMotion: vi.fn((duration: number) => duration),
|
||||
}));
|
||||
|
||||
import { AnimeMatch } from "../../../../src/ts/components/common/anime/AnimeMatch";
|
||||
import { AnimeSwitch } from "../../../../src/ts/components/common/anime/AnimeSwitch";
|
||||
|
||||
describe("AnimeSwitch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders the matched child", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={true}>
|
||||
<div data-testid="match-a">A</div>
|
||||
</AnimeMatch>
|
||||
<AnimeMatch when={false}>
|
||||
<div data-testid="match-b">B</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("match-a")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("match-b")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the next matched child reactively", () => {
|
||||
const [tab, setTab] = createSignal<"a" | "b">("a");
|
||||
|
||||
render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={tab() === "a"}>
|
||||
<div data-testid="view-a">View A</div>
|
||||
</AnimeMatch>
|
||||
<AnimeMatch when={tab() === "b"}>
|
||||
<div data-testid="view-b">View B</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId("view-a")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("view-b")).not.toBeInTheDocument();
|
||||
|
||||
setTab("b");
|
||||
|
||||
expect(screen.queryByTestId("view-a")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("view-b")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when no match", () => {
|
||||
const { container } = render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={false}>
|
||||
<div data-testid="no-match">never</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
expect(screen.queryByTestId("no-match")).not.toBeInTheDocument();
|
||||
// Only AnimePresence wrapper remains
|
||||
expect(container.querySelectorAll("[data-testid]").length).toBe(0);
|
||||
});
|
||||
|
||||
it("does not throw when switching between children", () => {
|
||||
const [view, setView] = createSignal<"a" | "b">("a");
|
||||
|
||||
expect(() => {
|
||||
render(() => (
|
||||
<AnimeSwitch exitBeforeEnter>
|
||||
<AnimeMatch when={view() === "a"}>
|
||||
<div>View A</div>
|
||||
</AnimeMatch>
|
||||
<AnimeMatch when={view() === "b"}>
|
||||
<div>View B</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => setView("b")).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes animeProps down to all AnimeMatch children", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch
|
||||
animeProps={{
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1, duration: 300 },
|
||||
exit: { opacity: 0, duration: 300 },
|
||||
}}
|
||||
>
|
||||
<AnimeMatch when={true}>
|
||||
<div>content</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
// Expect animate call with the shared animeProps
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 300 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AnimeMatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders children when `when` is true", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={true}>
|
||||
<div data-testid="match-content">match</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
expect(screen.getByTestId("match-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render children when `when` is false", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch>
|
||||
<AnimeMatch when={false}>
|
||||
<div data-testid="hidden">hidden</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
expect(screen.queryByTestId("hidden")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("per-match animate overrides the shared animeProps", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch
|
||||
animeProps={{
|
||||
animate: { opacity: 1, duration: 200 },
|
||||
}}
|
||||
>
|
||||
<AnimeMatch when={true} animate={{ opacity: 1, duration: 500 }}>
|
||||
<div>override</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
// The per-match duration (500) should be used, not the shared one (200)
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 500 }),
|
||||
);
|
||||
const callsWithSharedDuration = mockAnimate.mock.calls.filter(
|
||||
([, params]) => params.duration === 200,
|
||||
);
|
||||
expect(callsWithSharedDuration.length).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to context animeProps when no per-match props provided", () => {
|
||||
render(() => (
|
||||
<AnimeSwitch
|
||||
animeProps={{
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1, duration: 250 },
|
||||
}}
|
||||
>
|
||||
<AnimeMatch when={true}>
|
||||
<div>content</div>
|
||||
</AnimeMatch>
|
||||
</AnimeSwitch>
|
||||
));
|
||||
|
||||
// Should use context duration 250
|
||||
expect(mockAnimate).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement),
|
||||
expect.objectContaining({ opacity: 1, duration: 250 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user