import { parseHTML } from "linkedom";
import { describe, expect, it } from "vitest";
import {
splitElementInHtml,
unwrapElementsFromHtml,
wrapElementsInHtml,
} from "./sourceMutation.js";
describe("splitElementInHtml — hfId clone isolation", () => {
it("does not copy data-hf-id to the cloned second half", () => {
const source = `
`;
const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2");
expect(matched).toBe(true);
const { document } = parseHTML(html);
const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length;
expect(occurrences).toBe(1);
expect(document.getElementById("clip2")?.getAttribute("data-hf-id")).toMatch(/^hf-/);
expect(document.getElementById("clip2")?.getAttribute("data-hf-id")).not.toBe("hf-abc123");
});
});
describe("splitElementInHtml", () => {
const source = ``;
it("splits element at the given time", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('data-duration="2"');
expect(result.html).toContain('id="box-split"');
expect(result.html).toContain('data-start="3"');
expect(result.html).toContain('data-duration="4"');
});
it("canonicalizes legacy timing attributes on both split halves", () => {
const legacy = source.replace(
'data-start="1" data-duration="6"',
'data-start="1" data-end="7" data-layer="3"',
);
const result = splitElementInHtml(legacy, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).not.toContain("data-end=");
expect(result.html).not.toContain("data-layer=");
expect(result.html.match(/data-track-index="3"/g)).toHaveLength(2);
expect(result.html).toContain('data-duration="2"');
expect(result.html).toContain('data-duration="4"');
});
it("duplicates CSS rules for the new element ID", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toContain("#box-split");
expect(result.html).toContain("background: red");
const cssMatches = result.html.match(/#box-split\s*\{/g);
expect(cssMatches?.length).toBeGreaterThanOrEqual(1);
});
it("deduplicates IDs when the requested newId already exists", () => {
const withExisting = source.replace(
"",
'Existing
',
);
const result = splitElementInHtml(withExisting, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('id="box-split-2"');
});
it("keeps clip class on the cloned element", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/);
});
it("gives a split composition host a unique composition id", () => {
const composition = source.replace(
'id="box" class="clip"',
'id="box" class="clip" data-composition-id="headline" data-composition-src="headline.html"',
);
const first = splitElementInHtml(composition, { id: "box" }, 3, "box-split");
const second = splitElementInHtml(first.html, { id: "box-split" }, 4, "box-split-2");
const { document } = parseHTML(second.html);
const compositionIds = Array.from(
document.querySelectorAll("[data-composition-id]"),
(element) => element.getAttribute("data-composition-id"),
);
expect(compositionIds).toHaveLength(4);
expect(new Set(compositionIds)).toHaveLength(4);
expect(compositionIds).toEqual(expect.arrayContaining(["root", "headline", "headline-split"]));
});
it("returns matched false for out-of-range split time", () => {
expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false);
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
});
it("splits a GSAP element with no authored timing using fallback timing", () => {
const gsapSource = `Hi
`;
const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", {
start: 0,
duration: 6,
});
expect(result.matched).toBe(true);
const original = result.html.match(/]*\bid="title"[^>]*>/);
const clone = result.html.match(/]*\bid="title-split"[^>]*>/);
expect(original?.[0]).toContain('data-start="0"');
expect(original?.[0]).toContain('data-duration="2"');
expect(clone?.[0]).toContain('data-start="2"');
expect(clone?.[0]).toContain('data-duration="4"');
});
it("still rejects a no-timing element when no fallback timing is given", () => {
const gsapSource = `
Hi
`;
expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false);
});
it("adjusts media playback-start for the second half", () => {
const mediaSource = source.replace(
'id="box" class="clip" data-start="1" data-duration="6"',
'id="box" class="clip" data-start="1" data-duration="6" data-playback-start="0"',
);
const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/);
});
it("stamps a legacy composition offset and advances the second half by playback rate", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split", {
start: 1,
duration: 6,
playbackStart: 1.5,
playbackRate: 2,
stampPlaybackStart: true,
});
const { document } = parseHTML(result.html);
expect(document.getElementById("box")?.getAttribute("data-playback-start")).toBe("1.5");
expect(document.getElementById("box-split")?.getAttribute("data-playback-start")).toBe("5.5");
});
});
describe("wrapElementsInHtml / unwrapElementsFromHtml", () => {
const FIXTURE = ``;
const BBOX = { left: 260, top: 50, width: 300, height: 300 };
const REBASES = [
{ target: { id: "title" }, left: 0, top: 50 },
{ target: { id: "logo" }, left: 40, top: 150 },
{ target: { id: "badge" }, left: 140, top: 0 },
];
const TARGETS = [{ id: "title" }, { id: "logo" }, { id: "badge" }];
function leftTop(el: Element): { left: number; top: number } {
const style = el.getAttribute("style") ?? "";
const left = parseFloat(/(?:^|;)\s*left\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
const top = parseFloat(/(?:^|;)\s*top\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
return { left, top };
}
function requireElement(document: Document, selector: string): Element {
const element = document.querySelector(selector);
if (!element) throw new Error(`Expected ${selector} to match`);
return element;
}
it("wraps members in a data-hf-group div, preserving order and rebasing left/top", () => {
const { html, matched, groupId } = wrapElementsInHtml(
FIXTURE,
TARGETS,
"Group 1",
BBOX,
REBASES,
);
expect(matched).toBe(true);
expect(groupId).toBe("Group 1");
const { document } = parseHTML(html);
const group = requireElement(document, '[data-hf-group="Group 1"]');
expect(leftTop(group)).toEqual({ left: 260, top: 50 });
expect(Array.from(group.children).map((c) => c.id)).toEqual(["title", "logo", "badge"]);
expect(requireElement(document, "#outside").parentElement).toBe(
requireElement(document, '[data-composition-id="main"]'),
);
expect(leftTop(requireElement(document, "#title"))).toEqual({ left: 0, top: 50 });
expect(leftTop(requireElement(document, "#logo"))).toEqual({ left: 40, top: 150 });
expect(requireElement(document, "#logo").getAttribute("style")).toContain(
"transform: translate(10px, 5px)",
);
expect(leftTop(requireElement(document, "#badge"))).toEqual({ left: 140, top: 0 });
expect(requireElement(document, "#badge").getAttribute("style")).toContain(
"--hf-studio-offset: 12px",
);
});
it("round-trips: unwrap restores original structure and coordinates", () => {
const wrapped = wrapElementsInHtml(FIXTURE, TARGETS, "Group 1", BBOX, REBASES).html;
const { html, unwrapped } = unwrapElementsFromHtml(wrapped, {
selector: '[data-hf-group="Group 1"]',
});
expect(unwrapped).toBe(true);
const { document } = parseHTML(html);
expect(document.querySelector("[data-hf-group]")).toBeNull();
const main = requireElement(document, '[data-composition-id="main"]');
expect(Array.from(main.children).map((c) => c.id)).toEqual([
"title",
"logo",
"badge",
"outside",
]);
expect(leftTop(requireElement(document, "#title"))).toEqual({ left: 260, top: 100 });
expect(leftTop(requireElement(document, "#logo"))).toEqual({ left: 300, top: 200 });
expect(requireElement(document, "#logo").getAttribute("style")).toContain(
"transform: translate(10px, 5px)",
);
expect(leftTop(requireElement(document, "#badge"))).toEqual({ left: 400, top: 50 });
expect(requireElement(document, "#badge").getAttribute("style")).toContain(
"--hf-studio-offset: 12px",
);
});
it("rejects members that do not share a single parent", () => {
const split = ``;
const result = wrapElementsInHtml(split, [{ id: "a" }, { id: "b" }], "Group 1", BBOX, [
{ target: { id: "a" }, left: 0, top: 0 },
{ target: { id: "b" }, left: 0, top: 0 },
]);
expect(result.matched).toBe(false);
expect(result.error).toMatch(/single parent/);
expect(result.html).toBe(split);
});
it("lifts the group to the topmost member's slot so an interleaved non-member falls below it", () => {
const fixture = ``;
const { html, matched } = wrapElementsInHtml(
fixture,
[{ id: "low" }, { id: "high" }],
"Group 1",
{ left: 0, top: 0, width: 10, height: 10 },
[
{ target: { id: "low" }, left: 0, top: 0 },
{ target: { id: "high" }, left: 0, top: 0 },
],
);
expect(matched).toBe(true);
const { document } = parseHTML(html);
const parent = requireElement(document, '[data-composition-id="main"]');
const group = requireElement(document, '[data-hf-group="Group 1"]');
expect(Array.from(group.children).map((c) => c.id)).toEqual(["low", "high"]);
const topChildren = Array.from(parent.children).map(
(c) => c.getAttribute("data-hf-group") ?? c.id,
);
expect(topChildren).toEqual(["middle", "Group 1"]);
expect(group.getAttribute("style")).toMatch(/z-index:\s*4/);
});
it("refuses to unwrap an element without data-hf-group (no silent corruption)", () => {
const html = ``;
const result = unwrapElementsFromHtml(html, { id: "plain" });
expect(result.unwrapped).toBe(false);
expect(result.html).toBe(html);
});
});