import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { showContextMenuFallback } from "./contextMenuFallback"; type FakeListener = (event: FakeDomEvent) => void; class FakeDomEvent { defaultPrevented = false; constructor( readonly type: string, init: Record = {}, ) { Object.assign(this, init); } preventDefault() { this.defaultPrevented = true; } } class FakeElement { children: FakeElement[] = []; parent: FakeElement | null = null; style: Record & { cssText?: string } = {}; dataset: Record = {}; className = ""; disabled = false; type = ""; private textValue = ""; private readonly listeners = new Map(); constructor(readonly tagName: string) {} appendChild(child: FakeElement) { child.parent = this; this.children.push(child); return child; } remove() { if (!this.parent) { return; } const index = this.parent.children.indexOf(this); if (index >= 0) { this.parent.children.splice(index, 1); } this.parent = null; } addEventListener(type: string, listener: FakeListener) { const existing = this.listeners.get(type) ?? []; existing.push(listener); this.listeners.set(type, existing); } dispatchEvent(event: FakeDomEvent) { for (const listener of this.listeners.get(event.type) ?? []) { listener(event); } return true; } set textContent(value: string) { this.textValue = value; } get textContent() { return `${this.textValue}${this.children.map((child) => child.textContent).join("")}`; } querySelectorAll(tagName: string): FakeElement[] { const matches: FakeElement[] = []; if (this.tagName === tagName) { matches.push(this); } for (const child of this.children) { matches.push(...child.querySelectorAll(tagName)); } return matches; } getBoundingClientRect() { const left = Number.parseInt(this.style.left ?? "0", 10) || 0; const top = Number.parseInt(this.style.top ?? "0", 10) || 0; const width = this.tagName === "div" ? 180 : 140; const height = this.tagName === "div" ? 120 : 28; return { left, top, width, height, right: left + width, bottom: top + height, }; } } class FakeBody extends FakeElement { private html = ""; constructor() { super("body"); } set innerHTML(value: string) { this.html = value; this.children = []; } get innerHTML() { return this.html; } } class FakeDocument { body = new FakeBody(); private readonly listeners = new Map(); createElement(tagName: string) { return new FakeElement(tagName); } addEventListener(type: string, listener: FakeListener) { const existing = this.listeners.get(type) ?? []; existing.push(listener); this.listeners.set(type, existing); } removeEventListener(type: string, listener: FakeListener) { const existing = this.listeners.get(type); if (!existing) { return; } const index = existing.indexOf(listener); if (index >= 0) { existing.splice(index, 1); } } querySelectorAll(tagName: string) { return this.body.querySelectorAll(tagName); } } function findButton(label: string): FakeElement | undefined { return (document as unknown as FakeDocument) .querySelectorAll("button") .find((button) => button.textContent.includes(label)); } beforeEach(() => { vi.stubGlobal("document", new FakeDocument()); vi.stubGlobal("window", { innerWidth: 1280, innerHeight: 800, }); vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { callback(0); return 0; }); vi.stubGlobal( "MouseEvent", class extends FakeDomEvent { constructor(type: string, init: Record = {}) { super(type, init); } }, ); vi.stubGlobal( "KeyboardEvent", class extends FakeDomEvent { constructor(type: string, init: Record = {}) { super(type, init); } }, ); }); afterEach(() => { vi.unstubAllGlobals(); }); describe("showContextMenuFallback", () => { it("resolves a clicked flat menu item", async () => { const selectionPromise = showContextMenuFallback([ { id: "rename", label: "Rename" }, { id: "delete", label: "Delete", destructive: true }, ]); const renameButton = findButton("Rename"); expect(renameButton).toBeTruthy(); renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await expect(selectionPromise).resolves.toBe("rename"); }); it("ignores a click from the gesture that opened the menu", async () => { let enablePointerSelection: ((time: number) => void) | undefined; vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { enablePointerSelection = callback; return 0; }); const selectionPromise = showContextMenuFallback([{ id: "rename", label: "Rename" }]); const renameButton = findButton("Rename"); renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); enablePointerSelection?.(0); renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await expect(selectionPromise).resolves.toBe("rename"); }); it("opens nested submenus and resolves the clicked leaf id", async () => { const selectionPromise = showContextMenuFallback([ { id: "rename:submenu", label: "Rename project", children: [ { id: "rename:project-a", label: "/tmp/project-a" }, { id: "rename:project-b", label: "/tmp/project-b" }, ], }, ]); const parentButton = findButton("Rename project"); expect(parentButton).toBeTruthy(); parentButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); const childButton = findButton("/tmp/project-b"); expect(childButton).toBeTruthy(); childButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await expect(selectionPromise).resolves.toBe("rename:project-b"); }); });