Files
hyperframes/packages/studio/src/utils/studioHelpers.test.ts
T

198 lines
6.7 KiB
TypeScript

// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import {
findMatchingTimelineElementId,
findTimelineIdByAncestor,
resolveDroppedAssetDimensions,
resolveTimelineIdForSelection,
resolveTimelineSelectionSeekTime,
} from "./studioHelpers";
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("resolveTimelineSelectionSeekTime", () => {
it("keeps the current time when it is already inside the clip range", () => {
expect(resolveTimelineSelectionSeekTime(3, { start: 0, duration: 5 })).toBe(3);
});
it("clamps to the clip start when current time is before the clip", () => {
expect(resolveTimelineSelectionSeekTime(1, { start: 4, duration: 3 })).toBe(4);
});
it("clamps to the clip end when current time is after the clip", () => {
expect(resolveTimelineSelectionSeekTime(10, { start: 4, duration: 3 })).toBe(7);
});
it("falls back to the clip start for invalid current time", () => {
expect(resolveTimelineSelectionSeekTime(Number.NaN, { start: 2, duration: 5 })).toBe(2);
});
});
describe("findMatchingTimelineElementId", () => {
const el = (over: Record<string, unknown>) =>
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
it("matches a top-level element by domId + sourceFile", () => {
const els = [el({ id: "s1", domId: "s1", sourceFile: "index.html" })];
expect(findMatchingTimelineElementId({ id: "s1", sourceFile: "index.html" }, els)).toBe("s1");
});
it("returns a qualified id for a sub-comp child with no matching timeline element", () => {
const els = [el({ id: "s3", domId: "s3", sourceFile: "index.html" })];
expect(
findMatchingTimelineElementId(
{ id: "stat-3", sourceFile: "compositions/stats-panel.html" },
els,
),
).toBe("compositions/stats-panel.html#stat-3");
});
it("returns null for an unmatched element in index.html", () => {
expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null);
});
it("resolves the correct repeated composition host by domId, not the first host sharing the same compositionSrc", () => {
// Two hosts import the SAME sub-composition — only compositionSrc alone
// can't tell them apart, but each still has its own domId (as any
// element does). Selecting the SECOND host must not collapse to the
// first one just because an earlier, unrelated element also happens to
// share its compositionSrc.
const els = [
el({ id: "host-a", domId: "host-a", sourceFile: "index.html", compositionSrc: "scene.html" }),
el({ id: "host-b", domId: "host-b", sourceFile: "index.html", compositionSrc: "scene.html" }),
];
expect(
findMatchingTimelineElementId(
{
id: "host-b",
sourceFile: "index.html",
compositionSrc: "scene.html",
isCompositionHost: true,
},
els,
),
).toBe("host-b");
});
it("falls back to compositionSrc-only matching when the host selection has neither a domId nor a selector", () => {
const els = [
el({
id: "host-only",
domId: undefined,
sourceFile: "index.html",
compositionSrc: "scene.html",
}),
];
expect(
findMatchingTimelineElementId(
{
id: undefined,
sourceFile: "index.html",
compositionSrc: "scene.html",
isCompositionHost: true,
},
els,
),
).toBe("host-only");
});
});
describe("findTimelineIdByAncestor", () => {
const el = (over: Record<string, unknown>) =>
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
it("resolves a static descendant (.num) to its nearest clip ancestor", () => {
// #stat1 (a clip) > .num (selected, not a clip)
const stat1 = document.createElement("div");
stat1.id = "stat1";
const num = document.createElement("div");
num.className = "num";
stat1.appendChild(num);
const els = [el({ id: "stat1", domId: "stat1", key: "index.html#stat1" })];
expect(findTimelineIdByAncestor(num, els, "index.html")).toBe("index.html#stat1");
});
it("returns null when no ancestor is a clip", () => {
const wrap = document.createElement("div");
const child = document.createElement("span");
wrap.appendChild(child);
expect(findTimelineIdByAncestor(child, [], "index.html")).toBe(null);
});
});
describe("resolveTimelineIdForSelection", () => {
const el = (over: Record<string, unknown>) =>
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
it("resolves an ancestor clip against activeCompPath when the selection has no sourceFile", () => {
// #card (a clip in a sub-composition) > .leaf (selected, not itself a clip)
const card = document.createElement("div");
card.id = "card";
const leaf = document.createElement("span");
leaf.className = "leaf";
card.appendChild(leaf);
const els = [
el({
id: "card",
domId: "card",
key: "comps/panel.html#card",
sourceFile: "comps/panel.html",
}),
];
const selection = { element: leaf } as never;
// Falling back to the active comp matches; the old index.html-only fallback would miss.
expect(resolveTimelineIdForSelection(selection, els, "comps/panel.html")).toBe(
"comps/panel.html#card",
);
expect(resolveTimelineIdForSelection(selection, els, null)).toBe(null);
});
});
describe("resolveDroppedAssetDimensions", () => {
it("aborts an image probe when metadata times out", async () => {
vi.useFakeTimers();
const probe = {
addEventListener: vi.fn(),
naturalHeight: 0,
naturalWidth: 0,
src: "",
};
vi.stubGlobal(
"Image",
vi.fn(() => probe),
);
const result = resolveDroppedAssetDimensions("demo", "assets/hung.png", "image");
await vi.advanceTimersByTimeAsync(3000);
await expect(result).resolves.toBeNull();
expect(probe.src).toBe("");
});
it("aborts a video probe when metadata times out", async () => {
vi.useFakeTimers();
const video = document.createElement("video");
const load = vi.fn();
Object.defineProperty(video, "load", { configurable: true, value: load });
const createElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName, options) =>
tagName === "video" ? video : createElement(tagName, options),
);
const result = resolveDroppedAssetDimensions("demo", "assets/hung.mp4", "video");
await vi.advanceTimersByTimeAsync(3000);
await expect(result).resolves.toBeNull();
expect(video.getAttribute("src")).toBe("");
expect(load).toHaveBeenCalledOnce();
});
});