Download
123 lines 3.7 KiBJavaScriptModified 2026-08-01 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
// Drives the tooltip through real pointer events: title interception, the
// show delay, live updates while hovered, and yielding to popup claims.

import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { initTooltips } from "../src/lib/tooltip.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;
}

const ZERO_RECT = { x: 0, y: 0, width: 0, height: 0 };
const SHOW_DELAY = 350;

let invoke;

beforeAll(() => {
  initTooltips(); // installs document-level listeners once
});

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

afterEach(() => {
  document.dispatchEvent(new Event("pointerdown")); // hide any tooltip
  initCoordinator(() => Promise.resolve());
  release(claim({ el: document.createElement("div"), kind: "popup" }));
  vi.useRealTimers();
  document.body.replaceChildren();
});

function makeChip(title) {
  const el = document.createElement("div");
  el.title = title;
  document.body.appendChild(el);
  return el;
}

function hover(el) {
  el.dispatchEvent(new Event("pointerover", { bubbles: true }));
}

function unhover(el) {
  el.dispatchEvent(new MouseEvent("pointerout", { bubbles: true }));
}

describe("initTooltips", () => {
  it("stashes the title on hover and shows after the delay", () => {
    const el = makeChip("hello");
    hover(el);

    // Title intercepted immediately so the native tooltip never shows.
    expect(el.hasAttribute("title")).toBe(false);
    expect(el.dataset.tip).toBe("hello");
    expect(document.querySelector(".dtooltip")).toBeNull();

    vi.advanceTimersByTime(SHOW_DELAY);

    expect(document.querySelector(".dtooltip").textContent).toBe("hello");
    expect(invoke.shapeCalls()).toEqual([{ open: true, rect: ZERO_RECT }]);
  });

  it("live-updates the tooltip when the title is re-set while hovered", async () => {
    const el = makeChip("old");
    hover(el);
    vi.advanceTimersByTime(SHOW_DELAY);

    el.title = "new";
    await Promise.resolve(); // MutationObserver delivery

    expect(el.dataset.tip).toBe("new");
    expect(document.querySelector(".dtooltip").textContent).toBe("new");
  });

  it("hides on pointerout and shrinks the shape", () => {
    const el = makeChip("hello");
    hover(el);
    vi.advanceTimersByTime(SHOW_DELAY);

    unhover(el);

    expect(document.querySelector(".dtooltip")).toBeNull();
    expect(invoke.shapeCalls().at(-1)).toEqual({ open: false, rect: null });
  });

  it("does not show while a popup holds the space", () => {
    const popupEl = document.createElement("div");
    document.body.appendChild(popupEl);
    const popup = claim({ el: popupEl, kind: "popup" });

    const el = makeChip("hello");
    hover(el);
    vi.advanceTimersByTime(SHOW_DELAY);

    expect(document.querySelector(".dtooltip")).toBeNull();
    // The yield must not shrink the shape the popup owns.
    expect(invoke.shapeCalls().filter((s) => !s.open)).toHaveLength(0);
    release(popup);
  });

  it("is evicted when a popup claims the space, leaving the popup's shape intact", () => {
    const el = makeChip("hello");
    hover(el);
    vi.advanceTimersByTime(SHOW_DELAY);
    expect(document.querySelector(".dtooltip")).not.toBeNull();

    const popupEl = document.createElement("div");
    document.body.appendChild(popupEl);
    claim({ el: popupEl, kind: "popup" });

    expect(document.querySelector(".dtooltip")).toBeNull();
    expect(invoke.shapeCalls().at(-1)).toEqual({ open: true, rect: ZERO_RECT });
    expect(invoke.shapeCalls().filter((s) => !s.open)).toHaveLength(0);
  });
});