Download
232 lines 6.5 KiBJavaScriptModified 2026-08-01 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
// Drives createPopup through its interface: a fake invoke behind the
// Coordinator records the set_popup_open traffic, fake timers drive the
// pointer-leave close path.

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createPopup } from "../src/lib/popup.js";
import { initCoordinator, claim, release } from "../src/lib/coordinator.js";

function fakeInvoke() {
  const invoke = vi.fn(() => Promise.resolve());
  invoke.shapeCalls = () =>
    invoke.mock.calls
      .filter(([cmd]) => cmd === "set_popup_open")
      .map(([, args]) => args);
  return invoke;
}

function makeAnchor() {
  const anchor = document.createElement("button");
  document.body.appendChild(anchor);
  return anchor;
}

const flushMicrotasks = () => Promise.resolve();

let invoke;

beforeEach(() => {
  vi.useFakeTimers();
  invoke = fakeInvoke();
  initCoordinator(invoke);
});

afterEach(async () => {
  // Leave the popup space empty for the next test.
  initCoordinator(() => Promise.resolve());
  release(claim({ el: document.createElement("div"), kind: "popup" }));
  vi.useRealTimers();
  await flushMicrotasks();
  document.body.replaceChildren();
});

describe("createPopup", () => {
  it("open() renders the body into a .dpanel and widens the shape", () => {
    const render = vi.fn((el, greeting) => {
      el.textContent = greeting;
    });
    const popup = createPopup({
      anchor: makeAnchor(),
      className: "dtest",
      render,
    });

    expect(popup.isOpen()).toBe(false);
    popup.open("hello");

    expect(popup.isOpen()).toBe(true);
    const el = document.querySelector(".dpanel.dtest");
    expect(el).not.toBeNull();
    expect(el.textContent).toBe("hello");
    expect(render).toHaveBeenCalledOnce();
    expect(invoke.shapeCalls()).toEqual([
      { open: true, rect: { x: 0, y: 0, width: 0, height: 0 } },
    ]);

    // Re-opening while open is a no-op.
    popup.open("again");
    expect(render).toHaveBeenCalledOnce();
  });

  it("close() removes the panel and shrinks the shape", async () => {
    const popup = createPopup({
      anchor: makeAnchor(),
      className: "dtest",
      render: () => {},
    });

    popup.open();
    popup.close();

    expect(popup.isOpen()).toBe(false);
    expect(invoke.shapeCalls().at(-1)).toEqual({ open: false, rect: null });
    await flushMicrotasks(); // out-animation finishes
    expect(document.querySelector(".dpanel")).toBeNull();
  });

  it("toggle() forwards args to render and round-trips", () => {
    const render = vi.fn();
    const popup = createPopup({
      anchor: makeAnchor(),
      className: "dtest",
      render,
    });

    popup.toggle("a", "b");
    expect(render).toHaveBeenCalledWith(expect.anything(), "a", "b");
    popup.toggle();
    expect(popup.isOpen()).toBe(false);
  });

  it("a pointerdown outside closes; inside the popup or anchor does not", () => {
    const anchor = makeAnchor();
    const outside = document.createElement("div");
    document.body.appendChild(outside);
    const popup = createPopup({
      anchor,
      className: "dtest",
      render: () => {},
    });

    popup.open();
    const el = document.querySelector(".dpanel");
    el.dispatchEvent(new Event("pointerdown", { bubbles: true }));
    anchor.dispatchEvent(new Event("pointerdown", { bubbles: true }));
    expect(popup.isOpen()).toBe(true);

    outside.dispatchEvent(new Event("pointerdown", { bubbles: true }));
    expect(popup.isOpen()).toBe(false);
  });

  it("closes when the pointer leaves for longer than leaveDelay", () => {
    const popup = createPopup({
      anchor: makeAnchor(),
      className: "dtest",
      render: () => {},
      leaveDelay: 700,
    });

    popup.open();
    const el = document.querySelector(".dpanel");

    el.dispatchEvent(new Event("pointerleave"));
    vi.advanceTimersByTime(600);
    expect(popup.isOpen()).toBe(true);

    // Coming back disarms the timer entirely.
    el.dispatchEvent(new Event("pointerenter"));
    vi.advanceTimersByTime(2000);
    expect(popup.isOpen()).toBe(true);

    el.dispatchEvent(new Event("pointerleave"));
    vi.advanceTimersByTime(700);
    expect(popup.isOpen()).toBe(false);
  });

  it("holdOpen re-arms the leave timer instead of closing", () => {
    let dragging = true;
    const popup = createPopup({
      anchor: makeAnchor(),
      className: "dtest",
      render: () => {},
      leaveDelay: 700,
      holdOpen: () => dragging,
    });

    popup.open();
    document.querySelector(".dpanel").dispatchEvent(new Event("pointerleave"));

    vi.advanceTimersByTime(2100); // three timer rounds, all held open
    expect(popup.isOpen()).toBe(true);

    dragging = false;
    vi.advanceTimersByTime(700);
    expect(popup.isOpen()).toBe(false);
  });

  it("onClose fires however the popup closes", () => {
    const onClose = vi.fn();
    const outside = document.createElement("div");
    document.body.appendChild(outside);
    const popup = createPopup({
      anchor: makeAnchor(),
      className: "dtest",
      render: () => {},
      onClose,
    });

    popup.open();
    popup.close();
    expect(onClose).toHaveBeenCalledOnce();

    popup.open();
    outside.dispatchEvent(new Event("pointerdown", { bubbles: true }));
    expect(onClose).toHaveBeenCalledTimes(2);
  });

  it("opening a second popup closes the first (shared shape)", () => {
    const a = createPopup({
      anchor: makeAnchor(),
      className: "da",
      render: () => {},
    });
    const b = createPopup({
      anchor: makeAnchor(),
      className: "db",
      render: () => {},
    });

    a.open();
    b.open();
    expect(a.isOpen()).toBe(false);
    expect(b.isOpen()).toBe(true);
    // The eviction never shrank the shape b now owns.
    expect(invoke.shapeCalls().filter((s) => !s.open)).toHaveLength(0);
  });

  it("a content resize re-sends the shape while open, and not after close", () => {
    const popup = createPopup({
      anchor: makeAnchor(),
      className: "dtest",
      render: () => {},
    });

    popup.open();
    ResizeObserver.trigger();
    expect(invoke.shapeCalls().filter((s) => s.open)).toHaveLength(2);

    popup.close();
    ResizeObserver.trigger();
    expect(invoke.shapeCalls().at(-1)).toEqual({ open: false, rect: null });
  });
});

describe("popup library callers", () => {
  it("all popup-space modules import cleanly against the new seam", async () => {
    await import("../src/lib/slider-popup.js");
    await import("../src/lib/menu-popup.js");
    await import("../src/lib/calendar-popup.js");
    await import("../src/lib/widget-settings-popup.js");
    await import("../src/lib/tooltip.js");
  });
});