123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 | // Drives the bar layout editor through its interface against jsdom: a
// fake #bar with widget children, a fake invoke recording save_config
// calls, and synthetic pointer events for the drag path (element rects
// are stubbed — jsdom has no layout).
import { describe, it, expect, vi, afterEach } from "vitest";
import { createLayoutEditor, reorderTarget } from "../src/lib/layout-edit.js";
import { initCoordinator } from "../src/lib/coordinator.js";
import WIDGET_NAMES from "../src/widgets/index.js";
// The editor's toolbar popup claims the popup space through the Coordinator.
initCoordinator(() => Promise.resolve());
function stubRect(el, left, width) {
el.getBoundingClientRect = () => ({
left,
width,
right: left + width,
x: left,
y: 0,
top: 0,
bottom: 30,
height: 30,
});
}
function makeBar(entries) {
const barEl = document.createElement("div");
barEl.id = "bar";
document.body.appendChild(barEl);
const els = new Map();
entries.forEach((entry, i) => {
const el = document.createElement("div");
el.className = `fake-${entry.widget}`;
stubRect(el, i * 100, 90);
barEl.appendChild(el);
els.set(el, entry);
});
return { barEl, els };
}
function makeEditor(entries) {
const config = { bar: { height: 48 }, widgets: entries };
const { barEl, els } = makeBar(entries);
const invoke = vi.fn(() => Promise.resolve());
invoke.savedWidgets = () =>
invoke.mock.calls
.filter(([cmd]) => cmd === "save_config")
.map(([, args]) => args.config.widgets);
const editor = createLayoutEditor({
barEl,
invoke,
getConfig: () => config,
entryFor: (el) => els.get(el) ?? null,
});
return { editor, barEl, els, invoke, config };
}
function pointer(type, target, clientX = 0) {
target.dispatchEvent(
new MouseEvent(type, { bubbles: true, cancelable: true, clientX }),
);
}
const entries = () => [
{ widget: "date", options: {} },
{ widget: "time", options: { seconds: true } },
{ widget: "wifi", options: {} },
];
let editor;
afterEach(async () => {
editor?.stop();
editor = null;
await Promise.resolve(); // popup out-animation finishes
document.body.replaceChildren();
document.documentElement.className = "";
});
describe("reorderTarget", () => {
it("returns the first sibling whose midpoint is right of x", () => {
const { barEl } = makeBar(entries());
const [a, b, c] = barEl.children;
// Midpoints: a=45, b=145, c=245. Dragging c leftwards:
expect(reorderTarget(barEl, c, 30)).toBe(a);
expect(reorderTarget(barEl, c, 100)).toBe(b);
expect(reorderTarget(barEl, c, 200)).toBe(null); // stays at the end
expect(reorderTarget(barEl, a, 300)).toBe(null); // past everything
});
});
describe("createLayoutEditor", () => {
it("start() enters edit mode: root class, badges, toolbar with controls", () => {
({ editor } = makeEditor(entries()));
expect(editor.isActive()).toBe(false);
editor.start();
expect(editor.isActive()).toBe(true);
expect(document.documentElement.classList.contains("layout-editing")).toBe(true);
expect(document.querySelectorAll("#bar .layout-x")).toHaveLength(3);
const toolbar = document.querySelector(".dpanel.layout-toolbar");
expect(toolbar).not.toBeNull();
const labels = [...toolbar.querySelectorAll("button")].map((b) => b.textContent);
expect(labels).toContain("Done");
expect(labels).toContain("+ Add widget");
});
it("Done exits edit mode and removes all edit chrome", async () => {
({ editor } = makeEditor(entries()));
editor.start();
const done = [...document.querySelectorAll(".layout-toolbar button")].find(
(b) => b.textContent === "Done",
);
done.click();
expect(editor.isActive()).toBe(false);
expect(document.documentElement.classList.contains("layout-editing")).toBe(false);
expect(document.querySelectorAll(".layout-x")).toHaveLength(0);
await Promise.resolve();
expect(document.querySelector(".dpanel")).toBeNull();
});
it("Escape also exits", () => {
({ editor } = makeEditor(entries()));
editor.start();
document.body.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
expect(editor.isActive()).toBe(false);
});
it("✕ badge saves the config without that widget", () => {
let invoke, barEl;
({ editor, invoke, barEl } = makeEditor(entries()));
editor.start();
barEl.children[1].querySelector(".layout-x").click();
expect(invoke.savedWidgets()).toEqual([[entries()[0], entries()[2]]]);
});
it("dragging a widget reorders the DOM live and saves the new order", () => {
let invoke, barEl;
({ editor, invoke, barEl } = makeEditor(entries()));
editor.start();
const [date, time, wifi] = barEl.children;
pointer("pointerdown", wifi, 250);
pointer("pointermove", barEl, 30); // past the 5px threshold, before date's midpoint
expect([...barEl.children]).toEqual([wifi, date, time]);
expect(wifi.classList.contains("layout-dragging")).toBe(true);
pointer("pointerup", barEl);
expect(wifi.classList.contains("layout-dragging")).toBe(false);
expect(invoke.savedWidgets().at(-1).map((e) => e.widget)).toEqual([
"wifi",
"date",
"time",
]);
});
it("a sub-threshold press is not a drag and saves nothing", () => {
let invoke, barEl;
({ editor, invoke, barEl } = makeEditor(entries()));
editor.start();
pointer("pointerdown", barEl.children[0], 10);
pointer("pointermove", barEl, 12);
pointer("pointerup", barEl);
expect(invoke.savedWidgets()).toEqual([]);
});
it("clicks and contextmenu on widgets are swallowed while editing", () => {
let barEl;
({ editor, barEl } = makeEditor(entries()));
const widgetClick = vi.fn();
barEl.children[0].addEventListener("click", widgetClick);
editor.start();
pointer("click", barEl.children[0], 10);
expect(widgetClick).not.toHaveBeenCalled();
const menu = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
barEl.children[0].dispatchEvent(menu);
expect(menu.defaultPrevented).toBe(true);
editor.stop();
pointer("click", barEl.children[0], 10);
expect(widgetClick).toHaveBeenCalledOnce();
});
it("the add menu lists the manifest and appends the picked widget", () => {
let invoke;
({ editor, invoke } = makeEditor(entries()));
editor.start();
const add = [...document.querySelectorAll(".layout-toolbar button")].find(
(b) => b.textContent === "+ Add widget",
);
add.click();
const items = [...document.querySelectorAll(".layout-add-menu button")];
expect(items.map((b) => b.textContent)).toEqual([...WIDGET_NAMES].sort());
items.find((b) => b.textContent === "weather").click();
const saved = invoke.savedWidgets().at(-1);
expect(saved.map((e) => e.widget)).toEqual(["date", "time", "wifi", "weather"]);
expect(saved.at(-1)).toEqual({ widget: "weather", options: {} });
expect(document.querySelector(".layout-add-menu").childElementCount).toBe(0);
});
it("refresh() badges widgets added after start, once each", () => {
let barEl, els;
({ editor, barEl, els } = makeEditor(entries()));
editor.start();
const added = document.createElement("div");
stubRect(added, 300, 90);
barEl.appendChild(added);
els.set(added, { widget: "weather", options: {} });
editor.refresh();
editor.refresh();
expect(added.querySelectorAll(".layout-x")).toHaveLength(1);
expect(barEl.querySelectorAll(".layout-x")).toHaveLength(4);
});
});
|