lib/board-drop-animation.test.ts
Rama main · réplica de lectura del repositorio original.
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createBoardDropAnimation } from "./board-dnd";
describe("createBoardDropAnimation", () => {
const dropDestination = {
activeId: "issue",
position: 200,
status: "in_progress",
};
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({ matches: false }),
});
});
afterEach(() => {
document.body.replaceChildren();
delete (HTMLElement.prototype as Partial<HTMLElement>).animate;
vi.restoreAllMocks();
});
it("waits for the optimistic layout commit instead of guessing frame count", async () => {
let finishAnimation!: () => void;
const finished = new Promise<void>((resolve) => {
finishAnimation = resolve;
});
Object.defineProperty(HTMLElement.prototype, "animate", {
configurable: true,
value: vi.fn(() => ({ finished })),
});
const source = document.createElement("div");
source.dataset.issueId = "issue";
document.body.appendChild(source);
const overlay = document.createElement("div");
document.body.appendChild(overlay);
const coordinator = createBoardDropAnimation();
coordinator.prepare(dropDestination);
expect(typeof coordinator.animation).toBe("function");
if (typeof coordinator.animation !== "function") {
throw new Error("Expected a drop animation function");
}
const landing = coordinator.animation({
active: { id: "issue" },
dragOverlay: { node: overlay },
transform: { x: 0, y: 0, scaleX: 1, scaleY: 1 },
} as never);
expect(source.style.visibility).toBe("hidden");
expect(HTMLElement.prototype.animate).not.toHaveBeenCalled();
const destination = document.createElement("div");
destination.dataset.issueId = "issue";
source.replaceWith(destination);
coordinator.layoutCommitted(() => ({
id: "issue",
position: 200,
status: "in_progress",
}));
await Promise.resolve();
expect(destination.style.visibility).toBe("hidden");
expect(HTMLElement.prototype.animate).toHaveBeenCalledTimes(1);
finishAnimation();
await landing;
expect(destination.style.visibility).toBe("");
});
it("starts landing immediately when the rendered drop marker was measured", async () => {
let finishAnimation!: () => void;
const finished = new Promise<void>((resolve) => {
finishAnimation = resolve;
});
Object.defineProperty(HTMLElement.prototype, "animate", {
configurable: true,
value: vi.fn(() => ({ finished })),
});
const source = document.createElement("div");
source.dataset.issueId = "issue";
document.body.appendChild(source);
const overlay = document.createElement("div");
overlay.getBoundingClientRect = () =>
({ left: 20, top: 10, width: 100, height: 50 }) as DOMRect;
document.body.appendChild(overlay);
const coordinator = createBoardDropAnimation();
coordinator.prepare({
...dropDestination,
visualTarget: { left: 80, top: 60, width: 100, height: 50 },
});
if (typeof coordinator.animation !== "function") {
throw new Error("Expected a drop animation function");
}
const landing = coordinator.animation({
active: { id: "issue" },
dragOverlay: { node: overlay },
transform: { x: 5, y: 7, scaleX: 1, scaleY: 1 },
} as never);
expect(HTMLElement.prototype.animate).toHaveBeenCalledTimes(1);
expect(HTMLElement.prototype.animate).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
transform: "translate3d(65px, 57px, 0) scaleX(1) scaleY(1)",
}),
]),
expect.objectContaining({ duration: 180 }),
);
const destination = document.createElement("div");
destination.dataset.issueId = "issue";
source.replaceWith(destination);
coordinator.layoutCommitted(() => ({
id: "issue",
position: 200,
status: "in_progress",
}));
expect(destination.style.visibility).toBe("hidden");
finishAnimation();
await landing;
expect(destination.style.visibility).toBe("");
});
it("keeps the landing reserved until both animation and layout commit finish", async () => {
let finishAnimation!: () => void;
const finished = new Promise<void>((resolve) => {
finishAnimation = resolve;
});
Object.defineProperty(HTMLElement.prototype, "animate", {
configurable: true,
value: vi.fn(() => ({ finished })),
});
const source = document.createElement("div");
source.dataset.issueId = "issue";
document.body.appendChild(source);
const overlay = document.createElement("div");
document.body.appendChild(overlay);
const onFinish = vi.fn();
const coordinator = createBoardDropAnimation();
coordinator.prepare(
{
...dropDestination,
visualTarget: { left: 80, top: 60, width: 100, height: 50 },
},
onFinish,
);
if (typeof coordinator.animation !== "function") {
throw new Error("Expected a drop animation function");
}
const landing = coordinator.animation({
active: { id: "issue" },
dragOverlay: { node: overlay },
transform: { x: 0, y: 0, scaleX: 1, scaleY: 1 },
} as never);
finishAnimation();
await Promise.resolve();
expect(onFinish).not.toHaveBeenCalled();
coordinator.layoutCommitted(() => ({
id: "issue",
position: 200,
status: "in_progress",
}));
await landing;
expect(onFinish).toHaveBeenCalledTimes(1);
});
it("releases an immediate landing if dnd-kit never starts its animation", () => {
vi.useFakeTimers();
const onFinish = vi.fn();
const coordinator = createBoardDropAnimation();
coordinator.prepare(
{
...dropDestination,
visualTarget: { left: 80, top: 60, width: 100, height: 50 },
},
onFinish,
);
vi.advanceTimersByTime(2_000);
expect(onFinish).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it("restores visibility when WAAPI is cancelled", async () => {
Object.defineProperty(HTMLElement.prototype, "animate", {
configurable: true,
value: vi.fn(() => ({
finished: Promise.reject(new Error("cancelled")),
})),
});
const destination = document.createElement("div");
destination.dataset.issueId = "issue";
destination.getBoundingClientRect = () =>
({ left: 80, top: 60, width: 100, height: 50 }) as DOMRect;
document.body.appendChild(destination);
const overlay = document.createElement("div");
overlay.getBoundingClientRect = () =>
({ left: 20, top: 10, width: 100, height: 50 }) as DOMRect;
document.body.appendChild(overlay);
const coordinator = createBoardDropAnimation();
coordinator.prepare(dropDestination);
coordinator.layoutCommitted(() => ({
id: "issue",
position: 200,
status: "in_progress",
}));
if (typeof coordinator.animation !== "function") {
throw new Error("Expected a drop animation function");
}
await coordinator.animation({
active: { id: "issue" },
dragOverlay: { node: overlay },
transform: { x: 5, y: 7, scaleX: 1, scaleY: 1 },
} as never);
expect(destination.style.visibility).toBe("");
expect(HTMLElement.prototype.animate).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
transform: "translate3d(65px, 57px, 0) scaleX(1) scaleY(1)",
}),
]),
expect.objectContaining({ duration: 180 }),
);
});
it("ignores unrelated board commits before the dragged issue reaches its destination", async () => {
let finishAnimation!: () => void;
const finished = new Promise<void>((resolve) => {
finishAnimation = resolve;
});
Object.defineProperty(HTMLElement.prototype, "animate", {
configurable: true,
value: vi.fn(() => ({ finished, cancel: vi.fn() })),
});
const card = document.createElement("div");
card.dataset.issueId = "issue";
document.body.appendChild(card);
const overlay = document.createElement("div");
document.body.appendChild(overlay);
const coordinator = createBoardDropAnimation();
coordinator.prepare(dropDestination);
if (typeof coordinator.animation !== "function") {
throw new Error("Expected a drop animation function");
}
const landing = coordinator.animation({
active: { id: "issue" },
dragOverlay: { node: overlay },
transform: { x: 0, y: 0, scaleX: 1, scaleY: 1 },
} as never);
coordinator.layoutCommitted(() => ({
id: "issue",
position: 100,
status: "todo",
}));
await Promise.resolve();
expect(HTMLElement.prototype.animate).not.toHaveBeenCalled();
coordinator.layoutCommitted(() => ({
id: "issue",
position: 200,
status: "in_progress",
}));
await Promise.resolve();
expect(HTMLElement.prototype.animate).toHaveBeenCalledTimes(1);
finishAnimation();
await landing;
});
it("cancels an older landing without releasing ownership of the newer one", async () => {
let rejectFirst!: (error: Error) => void;
const cancelFirst = vi.fn(() => rejectFirst(new Error("cancelled")));
Object.defineProperty(HTMLElement.prototype, "animate", {
configurable: true,
value: vi.fn(() => ({
finished: new Promise<void>((_resolve, reject) => {
rejectFirst = reject;
}),
cancel: cancelFirst,
})),
});
const card = document.createElement("div");
card.dataset.issueId = "issue";
document.body.appendChild(card);
const overlay = document.createElement("div");
document.body.appendChild(overlay);
const firstFinished = vi.fn();
const secondFinished = vi.fn();
const coordinator = createBoardDropAnimation();
coordinator.prepare(dropDestination, firstFinished);
coordinator.layoutCommitted(() => ({
id: "issue",
position: 200,
status: "in_progress",
}));
if (typeof coordinator.animation !== "function") {
throw new Error("Expected a drop animation function");
}
const firstLanding = coordinator.animation({
active: { id: "issue" },
dragOverlay: { node: overlay },
transform: { x: 0, y: 0, scaleX: 1, scaleY: 1 },
} as never);
await Promise.resolve();
coordinator.prepare(
{ activeId: "next", position: 300, status: "done" },
secondFinished,
);
await firstLanding;
expect(cancelFirst).toHaveBeenCalledTimes(1);
expect(firstFinished).toHaveBeenCalledTimes(1);
expect(secondFinished).not.toHaveBeenCalled();
coordinator.cancel();
expect(secondFinished).toHaveBeenCalledTimes(1);
});
});