Files
hyperframes/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts
T
ukimsanov 760b88a6f3 fix(studio): flashless z-order commits and visible-overlap stepping
Two legibility fixes for the canvas z-order menu, from user feel-testing:

- z-only commits no longer remount the preview iframe. The commit hook
  already applies the inline z (+ injected position) to the live elements and
  updates the store synchronously; the post-commit reloadPreview() was a
  redundant full remount that read as a canvas 'blink' on every action.
  commitDomEditPatchBatches gains skipReload, engaged only when provably
  safe: every op is an inline-style patch AND the server reports every patch
  matched — anything else falls back to the reload so the preview reconverges
  with disk. The file-watcher's own reload stays suppressed by the existing
  domEditSaveTimestampRef window, so the skip is real.
- Bring Forward / Send Backward step over the next VISIBLY overlapping
  sibling. The nearest z-neighbor in a composition is often invisible at the
  current frame (runtime hides time-inactive clips with inline
  visibility/display; GSAP parks elements at opacity 0), so the step crossed
  something the user couldn't see — 'enabled but nothing happens'. The
  forward/backward set now filters on element-level computed visibility
  (display/visibility/opacity, injectable for tests); enable/disable shares
  the resolver so the menu is honest: actions disable when no visible
  neighbor exists. Front/back keep the full painting family.
- The neighbor that was stepped over gets a 600ms accent flash, drawn in the
  studio overlay layer (never in the iframe DOM), so the action shows its
  work.
2026-07-13 16:48:52 -07:00

620 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
isElementVisibleForZOrder,
isZOrderActionEnabled,
parseZIndex,
resolveCrossedNeighbor,
resolveZOrderChange,
type ZOrderAction,
type ZOrderPatch,
} from "./canvasContextMenuZOrder";
// ── helpers ───────────────────────────────────────────────────────────────────
//
// In jsdom getBoundingClientRect returns all zeros, so the target rect is 0×0
// and getOverlappingFamily returns the whole family — i.e. every sibling is
// treated as overlapping. That makes forward/backward and front/back exercise
// the same scoped set here, which is exactly what we want for order logic.
function makeEl(id: string, zIndex?: string): HTMLElement {
const el = document.createElement("div");
el.id = id;
if (zIndex !== undefined) el.style.zIndex = zIndex;
return el;
}
/**
* Build a parent and append `[target, ...siblings]` in DOM order. Each spec is
* [id, z]. The target's z is `targetZ`; it is appended FIRST unless
* `targetLast` is set, in which case it is appended LAST (later in DOM).
*/
function makeFamily(
targetZ: string,
siblingSpecs: Array<[string, string]>,
opts: { targetLast?: boolean } = {},
): { target: HTMLElement; parent: HTMLElement; byId: Record<string, HTMLElement> } {
const parent = document.createElement("div");
const target = makeEl("target", targetZ);
const siblings = siblingSpecs.map(([id, z]) => makeEl(id, z));
const byId: Record<string, HTMLElement> = { target };
for (const s of siblings) byId[s.id] = s;
if (opts.targetLast) {
for (const s of siblings) parent.appendChild(s);
parent.appendChild(target);
} else {
parent.appendChild(target);
for (const s of siblings) parent.appendChild(s);
}
return { target, parent, byId };
}
/** Resolve a z-order change and assert it produced patches (fails otherwise). */
function resolveZOrderPatches(
target: HTMLElement,
action: ZOrderAction,
options?: { isVisible?: (el: HTMLElement) => boolean },
): ZOrderPatch[] {
const patches = resolveZOrderChange(target, action, options);
expect(patches).not.toBeNull();
if (!patches) throw new Error("expected z-order patches");
return patches;
}
/** Look up a patch for a given element id in a patch list. */
function patchFor(patches: ZOrderPatch[], byId: Record<string, HTMLElement>, id: string) {
return patches.find((p) => p.element === byId[id]);
}
/** Apply patches, then return the render-ordered ids (bottom→top). */
function renderOrderIds(
parent: HTMLElement,
byId: Record<string, HTMLElement>,
patches: ZOrderPatch[],
): string[] {
for (const p of patches) p.element.style.zIndex = String(p.zIndex);
const children = Array.from(parent.children) as HTMLElement[];
const withPos = children.map((el, domIndex) => ({
id: Object.keys(byId).find((k) => byId[k] === el) ?? el.id,
z: parseZIndex(el.style.zIndex || "0"),
domIndex,
}));
withPos.sort((a, b) => a.z - b.z || a.domIndex - b.domIndex);
return withPos.map((e) => e.id);
}
// ── parseZIndex ───────────────────────────────────────────────────────────────
describe("parseZIndex", () => {
it("parses integers", () => {
expect(parseZIndex("5")).toBe(5);
expect(parseZIndex("0")).toBe(0);
expect(parseZIndex("-3")).toBe(-3);
});
it("treats 'auto' / null / undefined / empty as 0", () => {
expect(parseZIndex("auto")).toBe(0);
expect(parseZIndex(null)).toBe(0);
expect(parseZIndex(undefined)).toBe(0);
expect(parseZIndex("")).toBe(0);
});
});
// ── distinct-z fast path (single-element patch) ────────────────────────────────
describe("resolveZOrderChange distinct z values (fast path)", () => {
it("bring-to-front moves target above all with a single patch", () => {
const { target, byId } = makeFamily("2", [
["a", "1"],
["b", "5"],
["c", "3"],
]);
const patches = resolveZOrderPatches(target, "bring-to-front");
expect(patches).toHaveLength(1);
expect(patchFor(patches, byId, "target")?.zIndex).toBe(6);
});
it("bring-to-front returns null when already on top", () => {
const { target } = makeFamily("6", [
["a", "1"],
["b", "5"],
["c", "3"],
]);
expect(resolveZOrderChange(target, "bring-to-front")).toBeNull();
});
it("send-to-back moves target below all", () => {
const { target, byId, parent } = makeFamily("3", [
["a", "1"],
["b", "5"],
["c", "2"],
]);
const patches = resolveZOrderPatches(target, "send-to-back");
// target must end up strictly below the current min (1) in render order.
expect(renderOrderIds(parent, byId, patches)[0]).toBe("target");
});
it("send-to-back returns null when already at back", () => {
const { target } = makeFamily("0", [
["a", "1"],
["b", "5"],
["c", "3"],
]);
expect(resolveZOrderChange(target, "send-to-back")).toBeNull();
});
it("bring-forward steps up exactly one in render order", () => {
const { target, byId, parent } = makeFamily("2", [
["a", "1"],
["b", "4"],
["c", "7"],
]);
// render order bottom→top: a(1), target(2), b(4), c(7). forward → above b.
const patches = resolveZOrderPatches(target, "bring-forward");
expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "b", "target", "c"]);
});
it("send-backward steps down exactly one in render order", () => {
const { target, byId, parent } = makeFamily("5", [
["a", "1"],
["b", "3"],
["c", "8"],
]);
// bottom→top: a(1), b(3), target(5), c(8). backward → below b.
const patches = resolveZOrderPatches(target, "send-backward");
expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "target", "b", "c"]);
});
it("bring-forward returns null when already top of set", () => {
const { target } = makeFamily("8", [
["a", "1"],
["b", "4"],
["c", "7"],
]);
expect(resolveZOrderChange(target, "bring-forward")).toBeNull();
});
it("send-backward returns null when already bottom of set", () => {
const { target } = makeFamily("0", [
["a", "1"],
["b", "3"],
["c", "8"],
]);
expect(resolveZOrderChange(target, "send-backward")).toBeNull();
});
it("returns null when no siblings", () => {
const target = makeEl("solo", "2");
document.createElement("div").appendChild(target);
for (const action of [
"bring-forward",
"send-backward",
"bring-to-front",
"send-to-back",
] as ZOrderAction[]) {
expect(resolveZOrderChange(target, action)).toBeNull();
}
});
});
// ── DOM-order ties (the repro) ─────────────────────────────────────────────────
describe("resolveZOrderChange DOM-order ties (repro: equal z)", () => {
it("send-backward: tied target LATER in DOM (visually on top) can go below", () => {
// img#a (z=0, earlier in DOM) then video#target (z=0, later) → video paints
// on top. send-backward must put target below the image.
const { target, byId, parent } = makeFamily("0", [["a", "0"]], { targetLast: true });
const patches = resolveZOrderPatches(target, "send-backward");
expect(renderOrderIds(parent, byId, patches)).toEqual(["target", "a"]);
// target ends strictly below the image.
const tz = patchFor(patches, byId, "target")?.zIndex ?? 0;
expect(tz).toBeGreaterThanOrEqual(0);
});
it("send-to-back: tied target LATER in DOM goes to the very back", () => {
const { target, byId, parent } = makeFamily("0", [["a", "0"]], { targetLast: true });
const patches = resolveZOrderPatches(target, "send-to-back");
expect(renderOrderIds(parent, byId, patches)[0]).toBe("target");
});
it("bring-forward: tied target EARLIER in DOM (visually below) can go above", () => {
// target#target (z=0, earlier) then #a (z=0, later) → a paints on top.
// bring-forward on target must lift it above a.
const { target, byId, parent } = makeFamily("0", [["a", "0"]]);
const patches = resolveZOrderPatches(target, "bring-forward");
expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "target"]);
});
it("bring-to-front: tied target EARLIER in DOM goes to the very front", () => {
const { target, byId, parent } = makeFamily("0", [["a", "0"]]);
const patches = resolveZOrderPatches(target, "bring-to-front");
const order = renderOrderIds(parent, byId, patches);
expect(order[order.length - 1]).toBe("target");
});
it("send-backward: tied target EARLIER in DOM is already at back → null", () => {
// target earlier + a later, both z=0. target already paints below a.
const { target } = makeFamily("0", [["a", "0"]]);
expect(resolveZOrderChange(target, "send-backward")).toBeNull();
expect(resolveZOrderChange(target, "send-to-back")).toBeNull();
});
it("bring-forward: tied target LATER in DOM is already on top → null", () => {
const { target } = makeFamily("0", [["a", "0"]], { targetLast: true });
expect(resolveZOrderChange(target, "bring-forward")).toBeNull();
expect(resolveZOrderChange(target, "bring-to-front")).toBeNull();
});
it("renumber emits a real patch per changed element and none for the unchanged (minimal, no no-ops)", () => {
// Three tied at z=0, target in the middle of DOM order. Sending it back must
// renumber to distinct values but leave the target (which keeps its bottom
// slot's value 0) unpatched — and every emitted patch must be a genuine change.
const parent = document.createElement("div");
const a = makeEl("a", "0");
const target = makeEl("target", "0");
const b = makeEl("b", "0");
parent.append(a, target, b);
const originalZ = new Map<HTMLElement, number>([
[a, 0],
[target, 0],
[b, 0],
]);
// render order bottom→top by (z, dom): a, target, b. send-backward → below a.
const patches = resolveZOrderPatches(target, "send-backward");
// Every emitted patch is a REAL change: its new z differs from the old z.
for (const p of patches) expect(p.zIndex).not.toBe(originalZ.get(p.element));
// target renumbers to 0 (its existing value) → it must NOT be in the patch set.
expect(patchFor(patches, { a, target, b }, "target")).toBeUndefined();
// No two patches collide on the same element (a well-formed minimal set).
expect(new Set(patches.map((p) => p.element)).size).toBe(patches.length);
for (const p of patches) p.element.style.zIndex = String(p.zIndex);
expect(renderOrderIds(parent, { a, target, b }, [])).toEqual(["target", "a", "b"]);
});
});
// ── overlap scoping (real getBoundingClientRect) ────────────────────────────────
//
// jsdom's getBoundingClientRect is 0×0, so getOverlappingFamily keeps the whole
// family and the SCOPED (overlapping-only) path is never exercised above. These
// mock rects so a sibling can be genuinely NON-overlapping and thus non-scoped.
interface Rect {
left: number;
top: number;
right: number;
bottom: number;
}
function setRect(el: HTMLElement, r: Rect): void {
el.getBoundingClientRect = (): DOMRect =>
({
left: r.left,
top: r.top,
right: r.right,
bottom: r.bottom,
width: r.right - r.left,
height: r.bottom - r.top,
x: r.left,
y: r.top,
toJSON: () => ({}),
}) as DOMRect;
}
describe("resolveZOrderChange overlap scoping preserves untouched non-scoped pairs (#2202)", () => {
it("send-backward renumber keeps a scoped sibling above an untouched NON-overlapping one", () => {
// A (z5, overlaps target) and target (z5) are tied and overlap; C (z3) does NOT
// overlap target, so it is non-scoped. Old renumber sent the scoped set to
// 0..n-1 (A→1), dropping A BELOW C (z3) — an untouched (A, C) pair inverting.
// The band-preserving renumber keeps the scoped block above C: A→6, C untouched.
const parent = document.createElement("div");
const a = makeEl("a", "5");
const target = makeEl("target", "5");
const c = makeEl("c", "3");
parent.append(a, target, c);
setRect(a, { left: 0, top: 0, right: 10, bottom: 10 });
setRect(target, { left: 0, top: 0, right: 10, bottom: 10 });
setRect(c, { left: 100, top: 100, right: 110, bottom: 110 }); // disjoint → non-scoped
const byId = { a, target, c };
const patches = resolveZOrderPatches(target, "send-backward");
// C (untouched, non-scoped) is never patched.
expect(patchFor(patches, byId, "c")).toBeUndefined();
for (const p of patches) p.element.style.zIndex = String(p.zIndex);
const order = renderOrderIds(parent, byId, []);
// Deliberate move: target below a. Preserved untouched pair: a stays above c.
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
expect(order.indexOf("a")).toBeGreaterThan(order.indexOf("c"));
});
it("scopes forward/backward to the overlapping set (a non-overlapping sibling is ignored)", () => {
// target (z1) overlaps a (z2) only; far (z5) does not overlap target. bring-
// forward must step target above a (its sole overlapping neighbour), NOT chase
// the non-overlapping far — proving the scoping actually runs with real rects.
const parent = document.createElement("div");
const target = makeEl("target", "1");
const a = makeEl("a", "2");
const far = makeEl("far", "5");
parent.append(target, a, far);
setRect(target, { left: 0, top: 0, right: 10, bottom: 10 });
setRect(a, { left: 5, top: 5, right: 15, bottom: 15 }); // overlaps target
setRect(far, { left: 200, top: 200, right: 210, bottom: 210 }); // disjoint
const byId = { target, a, far };
const patches = resolveZOrderPatches(target, "bring-forward");
// far is untouched (not in the overlapping scope).
expect(patchFor(patches, byId, "far")).toBeUndefined();
for (const p of patches) p.element.style.zIndex = String(p.zIndex);
const order = renderOrderIds(parent, byId, []);
// target rose just above its overlapping neighbour a, staying below far.
expect(order.indexOf("target")).toBeGreaterThan(order.indexOf("a"));
expect(order.indexOf("target")).toBeLessThan(order.indexOf("far"));
});
});
// ── non-painting sibling hygiene ───────────────────────────────────────────────
describe("resolveZOrderChange excludes non-painting siblings", () => {
it("ignores <audio>/<script>/<style> siblings in the family", () => {
// Parent holds: img#a (z0), <audio> (a prior renumber wrote z=2 onto it),
// video#target (z0, later in DOM), plus a <script> and <style>. Only the two
// painting elements should form the family — the audio's z=2 must NOT pad the
// renumber or count as a sibling above the target.
const parent = document.createElement("div");
const a = makeEl("a", "0");
const audio = document.createElement("audio");
audio.style.zIndex = "2";
const script = document.createElement("script");
const style = document.createElement("style");
const target = makeEl("target", "0");
parent.append(a, audio, script, style, target);
// target is later in DOM than a, tied at z=0 → paints on top. send-to-back
// must put it below a. If audio (z=2) were counted, the renumber would differ.
const patches = resolveZOrderPatches(target, "send-to-back");
// No patch may target the audio/script/style elements.
for (const p of patches) {
expect(p.element).not.toBe(audio);
expect(p.element).not.toBe(script);
expect(p.element).not.toBe(style);
}
// Order among the painting pair: target below a.
const order = renderOrderIds(parent, { a, target }, patches);
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
});
it("ignores <template>/<noscript> siblings in the family", () => {
// A renumber fallback once wrote z-index/position into <template> source
// markup because templates entered the sibling family. They never paint —
// exclude them like audio/script/style.
const parent = document.createElement("div");
const a = makeEl("a", "0");
const template = document.createElement("template");
template.style.zIndex = "2";
const noscript = document.createElement("noscript");
const target = makeEl("target", "0");
parent.append(a, template, noscript, target);
const patches = resolveZOrderPatches(target, "send-to-back");
for (const p of patches) {
expect(p.element).not.toBe(template);
expect(p.element).not.toBe(noscript);
}
const order = renderOrderIds(parent, { a, target }, patches);
expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
});
it("a lone painting element beside only non-painting siblings has no family → null", () => {
const parent = document.createElement("div");
const target = makeEl("target", "1");
const audio = document.createElement("audio");
parent.append(target, audio);
// Only sibling is <audio> (excluded) → family size 1 → every action is a no-op.
for (const action of [
"bring-forward",
"send-backward",
"bring-to-front",
"send-to-back",
] as ZOrderAction[]) {
expect(resolveZOrderChange(target, action)).toBeNull();
}
});
});
// ── visibility scoping (forward/backward step over VISIBLE siblings only) ──────
//
// The visibility probe is injectable (ZOrderResolveOptions.isVisible) exactly
// like rect reading is stubbable — these tests drive the scoping logic with a
// stub, without a real style engine.
describe("resolveZOrderChange visibility scoping (injectable stub)", () => {
/** Probe that hides exactly the given elements. */
function hiding(...hidden: HTMLElement[]) {
return { isVisible: (el: HTMLElement) => !hidden.includes(el) };
}
it("bring-forward steps over the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
// Render order: target(1), hidden(2), vis(3). The nearest z-neighbor above
// is invisible at the current frame — forward must land the target above
// `vis` (the next VISIBLE overlapping sibling), leaving `hidden` untouched.
const { target, byId } = makeFamily("1", [
["hidden", "2"],
["vis", "3"],
]);
const patches = resolveZOrderPatches(target, "bring-forward", hiding(byId.hidden!));
expect(patches).toHaveLength(1);
expect(patchFor(patches, byId, "target")?.zIndex).toBe(4);
expect(patchFor(patches, byId, "hidden")).toBeUndefined();
});
it("send-backward steps below the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
// Render order: vis(1), hidden(2), target(3). Backward must drop the target
// below `vis`, not merely below the invisible `hidden`.
const { target, byId } = makeFamily("3", [
["vis", "1"],
["hidden", "2"],
]);
const patches = resolveZOrderPatches(target, "send-backward", hiding(byId.hidden!));
expect(patches).toHaveLength(1);
expect(patchFor(patches, byId, "target")?.zIndex).toBe(0);
expect(patchFor(patches, byId, "hidden")).toBeUndefined();
});
it("forward/backward are no-ops when every overlapping sibling is invisible", () => {
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
const opts = hiding(byId.hidden!);
expect(resolveZOrderChange(target, "bring-forward", opts)).toBeNull();
expect(resolveZOrderChange(target, "send-backward", opts)).toBeNull();
});
it("bring-to-front / send-to-back keep the FULL painting family (invisible siblings included)", () => {
// Unchanged semantics: front/back operate across all siblings, so an
// invisible sibling still counts and the actions stay meaningful.
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
const opts = hiding(byId.hidden!);
const patches = resolveZOrderPatches(target, "bring-to-front", opts);
expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
expect(resolveZOrderChange(target, "send-to-back", opts)).toBeNull(); // already bottom
});
it("the target itself is retained even when the probe reports it invisible", () => {
const { target, byId } = makeFamily("1", [["vis", "2"]]);
const patches = resolveZOrderPatches(target, "bring-forward", hiding(target));
expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
});
it("isZOrderActionEnabled matches the resolver under the same visibility scope", () => {
const { target, byId } = makeFamily("1", [["hidden", "2"]]);
const opts = hiding(byId.hidden!);
// Forward/backward: scoped set collapses to the target alone → disabled.
expect(isZOrderActionEnabled(target, "bring-forward", opts)).toBe(false);
expect(isZOrderActionEnabled(target, "send-backward", opts)).toBe(false);
// Front/back: full family → enabled exactly where the resolver acts.
expect(isZOrderActionEnabled(target, "bring-to-front", opts)).toBe(true);
expect(isZOrderActionEnabled(target, "send-to-back", opts)).toBe(false);
});
});
// ── default visibility probe (element-level computed style) ───────────────────
describe("isElementVisibleForZOrder default probe", () => {
function attachedEl(style: Partial<CSSStyleDeclaration> = {}): HTMLElement {
const el = document.createElement("div");
Object.assign(el.style, style);
document.body.appendChild(el);
return el;
}
it("treats display:none / visibility:hidden / opacity≈0 as invisible", () => {
expect(isElementVisibleForZOrder(attachedEl({ display: "none" }))).toBe(false);
expect(isElementVisibleForZOrder(attachedEl({ visibility: "hidden" }))).toBe(false);
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0" }))).toBe(false);
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.005" }))).toBe(false);
});
it("treats normal, translucent, and unstyled elements as visible", () => {
expect(isElementVisibleForZOrder(attachedEl())).toBe(true);
expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.5" }))).toBe(true);
expect(isElementVisibleForZOrder(attachedEl({ visibility: "visible" }))).toBe(true);
});
it("exempts a hidden color-grading source (its canvas paints in its place)", () => {
const el = attachedEl({ opacity: "0" });
el.setAttribute("data-hf-color-grading-source-hidden", "");
expect(isElementVisibleForZOrder(el)).toBe(true);
});
it("is the default probe: the runtime's inline visibility:hidden on a time-inactive clip is skipped", () => {
// End-to-end through resolveZOrderChange with NO injected probe: the
// runtime hides inactive clips with inline `visibility:hidden` (see core
// runtime syncTimedElementVisibility) — computed style picks that up.
const parent = document.createElement("div");
const target = makeEl("target", "1");
const hidden = makeEl("hidden", "2");
hidden.style.visibility = "hidden";
const vis = makeEl("vis", "3");
parent.append(target, hidden, vis);
document.body.appendChild(parent);
const patches = resolveZOrderPatches(target, "bring-forward");
expect(patchFor(patches, { target, hidden, vis }, "target")?.zIndex).toBe(4);
expect(patchFor(patches, { target, hidden, vis }, "hidden")).toBeUndefined();
});
});
// ── resolveCrossedNeighbor (the "show your work" flash target) ────────────────
describe("resolveCrossedNeighbor", () => {
it("returns the visible sibling directly above for bring-forward", () => {
const { target, byId } = makeFamily("1", [
["hidden", "2"],
["vis", "3"],
]);
const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
expect(resolveCrossedNeighbor(target, "bring-forward", opts)).toBe(byId.vis);
});
it("returns the visible sibling directly below for send-backward", () => {
const { target, byId } = makeFamily("3", [
["vis", "1"],
["hidden", "2"],
]);
const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
expect(resolveCrossedNeighbor(target, "send-backward", opts)).toBe(byId.vis);
});
it("returns null for front/back actions and for no-op steps", () => {
const { target } = makeFamily("1", [["a", "2"]]);
expect(resolveCrossedNeighbor(target, "bring-to-front")).toBeNull();
expect(resolveCrossedNeighbor(target, "send-to-back")).toBeNull();
expect(resolveCrossedNeighbor(target, "send-backward")).toBeNull(); // already bottom
const { target: top } = makeFamily("5", [["a", "2"]]);
expect(resolveCrossedNeighbor(top, "bring-forward")).toBeNull(); // already top
});
it("returns null when there are no siblings", () => {
const solo = makeEl("solo", "1");
document.createElement("div").appendChild(solo);
expect(resolveCrossedNeighbor(solo, "bring-forward")).toBeNull();
});
});
// ── isZOrderActionEnabled ─────────────────────────────────────────────────────
describe("isZOrderActionEnabled", () => {
it("mirrors resolveZOrderChange non-null", () => {
// target z=2 (DOM 0), a z=5 (DOM 1): render order = target, a. target is at
// the bottom, so forward/front are enabled and backward/back are no-ops.
const { target } = makeFamily("2", [["a", "5"]]);
expect(isZOrderActionEnabled(target, "bring-to-front")).toBe(true);
expect(isZOrderActionEnabled(target, "bring-forward")).toBe(true);
expect(isZOrderActionEnabled(target, "send-to-back")).toBe(false);
expect(isZOrderActionEnabled(target, "send-backward")).toBe(false);
});
it("false when already on top", () => {
const { target } = makeFamily("6", [
["a", "1"],
["b", "5"],
]);
expect(isZOrderActionEnabled(target, "bring-to-front")).toBe(false);
expect(isZOrderActionEnabled(target, "bring-forward")).toBe(false);
});
it("tie repro: send-backward enabled for a visually-on-top tied target", () => {
const { target } = makeFamily("0", [["a", "0"]], { targetLast: true });
expect(isZOrderActionEnabled(target, "send-backward")).toBe(true);
expect(isZOrderActionEnabled(target, "send-to-back")).toBe(true);
});
it("all actions disabled when there are no siblings", () => {
const target = makeEl("solo", "1");
document.createElement("div").appendChild(target);
for (const action of [
"bring-forward",
"send-backward",
"bring-to-front",
"send-to-back",
] as ZOrderAction[]) {
expect(isZOrderActionEnabled(target, action)).toBe(false);
}
});
});