Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
import { act, renderHook } from "@testing-library/react";
import { useWidgetDnD } from "content-src/components/Widgets/useWidgetDnD.jsx";
import { cursorToSlot } from "content-src/components/Widgets/useMouseDnD.jsx";
const DEFAULT_ORDER = [
"lists",
"focusTimer",
"weather",
"sportsWidget",
"clocks",
];
function makeProps(overrides = {}) {
return {
widgetOrder: DEFAULT_ORDER,
prefs: {},
dispatch: jest.fn(),
...overrides,
};
}
function setup(overrides = {}) {
const props = makeProps(overrides);
const { result } = renderHook(() => useWidgetDnD(props));
return { result, dispatch: props.dispatch, props };
}
function dataTransferStub(types = ["text/widget-id"], data = {}) {
return {
types,
setData: jest.fn((k, v) => {
data[k] = v;
}),
getData: jest.fn(k => data[k] || ""),
setDragImage: jest.fn(),
effectAllowed: "",
dropEffect: "",
};
}
function makeArticle(widgetId) {
const el = document.createElement("article");
el.setAttribute("data-widget-id", widgetId);
return el;
}
function dragEvent(opts = {}) {
const types = opts.types ?? ["text/widget-id"];
const data = opts.data ?? { "text/widget-id": opts.sourceId || "" };
const target = opts.target || opts.currentTarget;
const currentTarget = opts.currentTarget || opts.target;
return {
dataTransfer: dataTransferStub(types, data),
clientX: opts.clientX ?? 0,
clientY: opts.clientY ?? 0,
target,
currentTarget,
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
};
}
describe("cursorToSlot", () => {
const slotRects = [
{ left: 0, right: 200, top: 0, bottom: 100, width: 200, height: 100 },
{ left: 200, right: 400, top: 0, bottom: 100, width: 200, height: 100 },
{ left: 400, right: 600, top: 0, bottom: 100, width: 200, height: 100 },
];
it("returns the slot whose rect contains the cursor", () => {
expect(cursorToSlot(slotRects, 100, 50)).toBe(0);
expect(cursorToSlot(slotRects, 300, 50)).toBe(1);
expect(cursorToSlot(slotRects, 500, 50)).toBe(2);
});
it("returns null when the cursor is outside every rect (gaps/empty regions)", () => {
// Above all rects
expect(cursorToSlot(slotRects, 100, -10)).toBe(null);
// Below all rects
expect(cursorToSlot(slotRects, 100, 200)).toBe(null);
// Past the rightmost rect
expect(cursorToSlot(slotRects, 700, 50)).toBe(null);
});
it("treats rect edges as inclusive (right and bottom edges hit the slot)", () => {
expect(cursorToSlot(slotRects, 200, 50)).toBe(0); // first match wins on shared edge
expect(cursorToSlot(slotRects, 100, 100)).toBe(0);
});
it("returns null when slotRects is null (no in-flight drag)", () => {
expect(cursorToSlot(null, 100, 50)).toBe(null);
});
it("skips holes in slotRects", () => {
const rectsWithHole = [
{ left: 0, right: 200, top: 0, bottom: 100, width: 200, height: 100 },
null,
{ left: 400, right: 600, top: 0, bottom: 100, width: 200, height: 100 },
];
expect(cursorToSlot(rectsWithHole, 100, 50)).toBe(0);
expect(cursorToSlot(rectsWithHole, 300, 50)).toBe(null); // hole, no match
expect(cursorToSlot(rectsWithHole, 500, 50)).toBe(2);
});
});
describe("useWidgetDnD - mouse drag", () => {
it("foreign drop (no text/widget-id) does not dispatch", () => {
const { result, dispatch } = setup();
const e = dragEvent({ types: ["text/plain"] });
act(() => {
result.current.handleDrop(e);
});
expect(dispatch).not.toHaveBeenCalled();
expect(e.preventDefault).not.toHaveBeenCalled();
});
it("drop without an active drag does not dispatch", () => {
const { result, dispatch } = setup();
const e = dragEvent({
types: ["text/widget-id"],
data: { "text/widget-id": "lists" },
});
// No prior dragstart — sourceIdx is fine but targetSlot is null.
act(() => {
result.current.handleDrop(e);
});
expect(dispatch).not.toHaveBeenCalled();
});
it("foreign dragover (no text/widget-id) is left alone", () => {
const { result } = setup();
const e = dragEvent({ types: ["text/plain"] });
act(() => {
result.current.handleDragOver(e);
});
expect(e.preventDefault).not.toHaveBeenCalled();
expect(e.stopPropagation).not.toHaveBeenCalled();
});
});
describe("useWidgetDnD - interactive descendant guard", () => {
it("aborts the drag when mousedown was on a button inside the widget", () => {
const { result } = setup();
const article = makeArticle("lists");
const button = document.createElement("button");
article.appendChild(button);
document.body.appendChild(article);
act(() => {
result.current.handleMouseDown({ target: button });
});
const e = dragEvent({ currentTarget: article, target: article });
act(() => {
result.current.handleDragStart(e, "lists");
});
expect(e.preventDefault).toHaveBeenCalled();
expect(result.current.draggedId).toBe(null);
document.body.removeChild(article);
});
it("allows the drag when mousedown was on a non-interactive area", () => {
const { result } = setup();
const article = makeArticle("lists");
const inner = document.createElement("div");
article.appendChild(inner);
document.body.appendChild(article);
act(() => {
result.current.handleMouseDown({ target: inner });
});
const e = dragEvent({ currentTarget: article, target: article });
act(() => {
result.current.handleDragStart(e, "lists");
});
expect(e.preventDefault).not.toHaveBeenCalled();
expect(result.current.draggedId).toBe("lists");
document.body.removeChild(article);
});
it("aborts the drag when mousedown was inside an open dialog (modal should not drag the widget)", () => {
const { result } = setup();
const article = makeArticle("sportsWidget");
const dialog = document.createElement("dialog");
dialog.setAttribute("open", "");
const dialogContent = document.createElement("div");
dialog.appendChild(dialogContent);
article.appendChild(dialog);
document.body.appendChild(article);
act(() => {
result.current.handleMouseDown({ target: dialogContent });
});
const e = dragEvent({ currentTarget: article, target: article });
act(() => {
result.current.handleDragStart(e, "sportsWidget");
});
expect(e.preventDefault).toHaveBeenCalled();
expect(result.current.draggedId).toBe(null);
document.body.removeChild(article);
});
it("aborts the drag when mousedown was directly on the dialog backdrop", () => {
const { result } = setup();
const article = makeArticle("sportsWidget");
const dialog = document.createElement("dialog");
dialog.setAttribute("open", "");
article.appendChild(dialog);
document.body.appendChild(article);
act(() => {
result.current.handleMouseDown({ target: dialog });
});
const e = dragEvent({ currentTarget: article, target: article });
act(() => {
result.current.handleDragStart(e, "sportsWidget");
});
expect(e.preventDefault).toHaveBeenCalled();
expect(result.current.draggedId).toBe(null);
document.body.removeChild(article);
});
it("does not abort the drag when mousedown was on an anchor (anchors should drag the widget)", () => {
const { result } = setup();
const article = makeArticle("weather");
const anchor = document.createElement("a");
article.appendChild(anchor);
document.body.appendChild(article);
act(() => {
result.current.handleMouseDown({ target: anchor });
});
const e = dragEvent({ currentTarget: article, target: article });
act(() => {
result.current.handleDragStart(e, "weather");
});
expect(e.preventDefault).not.toHaveBeenCalled();
expect(result.current.draggedId).toBe("weather");
document.body.removeChild(article);
});
});