// @vitest-environment happy-dom import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness"; import { resolveZIndexEntries } from "../nle/PreviewOverlays"; import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps"; import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils"; import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes"; import { CanvasContextMenu } from "./CanvasContextMenu"; import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder"; import type { DomEditSelection } from "./domEditing"; installReactActEnvironment(); let host: HTMLDivElement; let root: Root | null = null; beforeEach(() => { host = document.createElement("div"); document.body.append(host); }); afterEach(() => { act(() => root?.unmount()); root = null; document.body.innerHTML = ""; }); function renderMenu(props: { selection: DomEditSelection; onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void; onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void; onDelete?: (selection: DomEditSelection) => void; }) { root = createRoot(host); act(() => { root!.render( React.createElement(CanvasContextMenu, { x: 10, y: 10, selection: props.selection, onClose: () => {}, onApplyZIndex: props.onApplyZIndex, onZOrderCrossed: props.onZOrderCrossed, onDelete: props.onDelete, }), ); }); } /** All menu buttons live in the portal under document.body. */ function menuButtons(): HTMLButtonElement[] { return [...document.body.querySelectorAll("button")]; } function hasDeleteItem(): boolean { return menuButtons().some((b) => b.textContent?.includes("Delete")); } function zOrderButtons(): HTMLButtonElement[] { return menuButtons().filter((b) => !b.textContent?.includes("Delete")); } describe("CanvasContextMenu — handler gating", () => { it("renders all four z-order items, a divider, and Delete when both handlers are present", () => { const el = document.createElement("div"); el.id = "target"; document.body.append(el); renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn(), onDelete: vi.fn(), }); expect(zOrderButtons()).toHaveLength(4); expect(hasDeleteItem()).toBe(true); // The divider only appears between the two groups. expect(document.body.querySelector(".border-t")).not.toBeNull(); }); it("hides every item and does NOT render the menu when no handlers are present", () => { const el = document.createElement("div"); el.id = "target"; // A z-index that a stray optimistic write would clobber — assert it is // untouched, since the menu must not mutate the DOM without a persist path. el.style.zIndex = "3"; document.body.append(el); renderMenu({ selection: makeSelection("Target", el) }); // No menu opened at all — no buttons, no dead-end items, no DOM mutation. expect(menuButtons()).toHaveLength(0); expect(document.body.querySelector(".fixed.z-50")).toBeNull(); expect(el.style.zIndex).toBe("3"); }); it("shows only the z-order items (no Delete, no divider) when onDelete is absent", () => { const el = document.createElement("div"); el.id = "target"; document.body.append(el); renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() }); const buttons = zOrderButtons(); expect(buttons).toHaveLength(4); expect(hasDeleteItem()).toBe(false); expect(document.body.querySelector(".border-t")).toBeNull(); // Labels stay the exact industry-standard names (the icons add no text)... expect(buttons.map((b) => b.textContent)).toEqual([ "Bring to front", "Bring forward", "Send backward", "Send to back", ]); // ...and each item leads with a stroke icon that inherits the item's text // color (currentColor), so the disabled muted tone applies to it too. for (const button of buttons) { const svg = button.firstElementChild; expect(svg?.tagName.toLowerCase()).toBe("svg"); expect(svg?.getAttribute("stroke")).toBe("currentColor"); expect(svg?.getAttribute("aria-hidden")).toBe("true"); } }); it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => { const el = document.createElement("div"); el.id = "target"; document.body.append(el); renderMenu({ selection: makeSelection("Target", el), onDelete: vi.fn() }); expect(zOrderButtons()).toHaveLength(0); expect(hasDeleteItem()).toBe(true); expect(document.body.querySelector(".border-t")).toBeNull(); }); }); // ── Menu z-action → commit path (wired the way PreviewOverlays wires the app) ── function pressMenuItem(label: string) { const button = zOrderButtons().find((b) => b.textContent === label); expect(button).toBeDefined(); act(() => { button!.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }), ); }); } /** Target (static, earlier in DOM) below an equal-z sibling — z action must renumber. */ function makeStaticFamily() { const parent = document.createElement("div"); const target = document.createElement("div"); target.id = "target"; // In happy-dom an unset computed position is "" (not "static"), which would // skip the commit hook's static-position injection; declare it explicitly so // the test exercises the browser default. target.style.position = "static"; const other = document.createElement("div"); other.id = "other"; parent.append(target, other); document.body.append(parent); return { parent, target, other }; } interface CapturedBatchCall { batches: DomEditPatchBatch[]; options: { label: string; coalesceKey: string }; } /** Mount the REAL commit hook (persist layer mocked at commitDomEditPatchBatches). */ function renderCommitHook(captured: CapturedBatchCall[]) { type Commit = ReturnType["handleDomZIndexReorderCommit"]; let commit: Commit | undefined; function Harness() { ({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps( makeLifecycleOpsParams({ commitDomEditPatchBatches: async (batches, options) => { captured.push({ batches, options }); return { durable: true, allMatched: true, changed: true }; }, }), )); return null; } const hookHost = document.createElement("div"); document.body.append(hookHost); const hookRoot = createRoot(hookHost); act(() => hookRoot.render()); return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) }; } describe("CanvasContextMenu — z-action commit path", () => { it("never mutates live styles itself and persists the position patch for a static element", async () => { const { target } = makeStaticFamily(); const selection = makeSelection("Target", target); const captured: CapturedBatchCall[] = []; const { commit, cleanup } = renderCommitHook(captured); // Wire onApplyZIndex the way the app does (PreviewOverlays → the commit // hook), asserting the menu has NOT touched the DOM when it fires — the // hook must capture true pre-change styles for its rollback. const stylesAtApply: Array<{ zIndex: string; position: string }> = []; renderMenu({ selection, onApplyZIndex: (patches, action) => { stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position }); const { entries } = resolveZIndexEntries(selection, patches); void commit(entries, undefined, action); }, }); await act(async () => pressMenuItem("Bring forward")); // The menu left the element pristine; only the commit hook wrote styles. expect(stylesAtApply).toEqual([{ zIndex: "", position: "static" }]); expect(target.style.zIndex).toBe("1"); expect(target.style.position).toBe("relative"); // The persisted payload carries BOTH the z-index and the injected position, // so the reorder survives the post-commit reloadPreview(). expect(captured).toHaveLength(1); const targetPatch = captured[0]?.batches .flatMap((batch) => batch.patches) .find((patch) => patch.target.id === "target"); expect(targetPatch?.operations).toEqual( expect.arrayContaining([ { type: "inline-style", property: "z-index", value: "1" }, { type: "inline-style", property: "position", value: "relative" }, ]), ); // F7: the action kind is part of the default undo coalesce key, so two // different menu actions never merge into one undo step. expect(captured[0]?.options.coalesceKey).toContain("bring-forward"); cleanup(); }); it("reports the crossed sibling to onZOrderCrossed for a forward step (resolved pre-mutation)", async () => { // target (earlier in DOM) and other are tied — bring-forward steps over // `other`, and the flash callback must receive exactly that element, after // onApplyZIndex ran (call order lets the host measure post-commit rects). const { target, other } = makeStaticFamily(); const selection = makeSelection("Target", target); const calls: Array<{ kind: string; crossed?: HTMLElement }> = []; renderMenu({ selection, onApplyZIndex: () => calls.push({ kind: "apply" }), onZOrderCrossed: (crossed, action) => { expect(action).toBe("bring-forward"); calls.push({ kind: "crossed", crossed }); }, }); await act(async () => pressMenuItem("Bring forward")); expect(calls.map((c) => c.kind)).toEqual(["apply", "crossed"]); expect(calls[1]?.crossed).toBe(other); }); it("does not call onZOrderCrossed for bring-to-front", async () => { const { target } = makeStaticFamily(); const onZOrderCrossed = vi.fn(); renderMenu({ selection: makeSelection("Target", target), onApplyZIndex: vi.fn(), onZOrderCrossed, }); await act(async () => pressMenuItem("Bring to front")); expect(onZOrderCrossed).not.toHaveBeenCalled(); }); });