Download
233 lines 7.5 KiBJavaScriptModified 2026-08-04 raw
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);
  });
});